Skip to content

Commit e2d03ff

Browse files
authored
cache NIOSSLContext (saves 27k allocs per conn) (#362)
Motivation: At the moment, AHC assumes that creating a `NIOSSLContext` is both cheap and doesn't block. Neither of these two assumptions are true. To create a `NIOSSLContext`, BoringSSL will have to read a lot of certificates in the trust store (on disk) which require a lot of ASN1 parsing and much much more. On my Ubuntu test machine, creating one `NIOSSLContext` is about 27,000 allocations!!! To make it worse, AHC allocates a fresh `NIOSSLContext` for _every single connection_, whether HTTP or HTTPS. Yes, correct. Modification: - Cache NIOSSLContexts per TLSConfiguration in a LRU cache - Don't get an NIOSSLContext for HTTP (plain text) connections Result: New connections should be _much_ faster in general assuming that you're not using a different TLSConfiguration for every connection.
1 parent ca722d8 commit e2d03ff

15 files changed

+720
-186
lines changed

Diff for: Sources/AsyncHTTPClient/ConnectionPool.swift

+18-6
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import NIO
1818
import NIOConcurrencyHelpers
1919
import NIOHTTP1
2020
import NIOHTTPCompression
21+
import NIOSSL
2122
import NIOTLS
2223
import NIOTransportServices
2324

@@ -41,6 +42,8 @@ final class ConnectionPool {
4142

4243
private let backgroundActivityLogger: Logger
4344

45+
let sslContextCache = SSLContextCache()
46+
4447
init(configuration: HTTPClient.Configuration, backgroundActivityLogger: Logger) {
4548
self.configuration = configuration
4649
self.backgroundActivityLogger = backgroundActivityLogger
@@ -106,6 +109,8 @@ final class ConnectionPool {
106109
self.providers.values
107110
}
108111

112+
self.sslContextCache.shutdown()
113+
109114
return EventLoopFuture.reduce(true, providers.map { $0.close() }, on: eventLoop) { $0 && $1 }
110115
}
111116

@@ -148,7 +153,7 @@ final class ConnectionPool {
148153
var host: String
149154
var port: Int
150155
var unixPath: String
151-
var tlsConfiguration: BestEffortHashableTLSConfiguration?
156+
private var tlsConfiguration: BestEffortHashableTLSConfiguration?
152157

153158
enum Scheme: Hashable {
154159
case http
@@ -249,14 +254,15 @@ class HTTP1ConnectionProvider {
249254
} else {
250255
logger.trace("opening fresh connection (found matching but inactive connection)",
251256
metadata: ["ahc-dead-connection": "\(connection)"])
252-
self.makeChannel(preference: waiter.preference).whenComplete { result in
257+
self.makeChannel(preference: waiter.preference,
258+
logger: logger).whenComplete { result in
253259
self.connect(result, waiter: waiter, logger: logger)
254260
}
255261
}
256262
}
257263
case .create(let waiter):
258264
logger.trace("opening fresh connection (no connections to reuse available)")
259-
self.makeChannel(preference: waiter.preference).whenComplete { result in
265+
self.makeChannel(preference: waiter.preference, logger: logger).whenComplete { result in
260266
self.connect(result, waiter: waiter, logger: logger)
261267
}
262268
case .replace(let connection, let waiter):
@@ -266,7 +272,7 @@ class HTTP1ConnectionProvider {
266272
logger.trace("opening fresh connection (replacing exising connection)",
267273
metadata: ["ahc-old-connection": "\(connection)",
268274
"ahc-waiter": "\(waiter)"])
269-
self.makeChannel(preference: waiter.preference).whenComplete { result in
275+
self.makeChannel(preference: waiter.preference, logger: logger).whenComplete { result in
270276
self.connect(result, waiter: waiter, logger: logger)
271277
}
272278
}
@@ -434,8 +440,14 @@ class HTTP1ConnectionProvider {
434440
return self.closePromise.futureResult.map { true }
435441
}
436442

437-
private func makeChannel(preference: HTTPClient.EventLoopPreference) -> EventLoopFuture<Channel> {
438-
return NIOClientTCPBootstrap.makeHTTP1Channel(destination: self.key, eventLoop: self.eventLoop, configuration: self.configuration, preference: preference)
443+
private func makeChannel(preference: HTTPClient.EventLoopPreference,
444+
logger: Logger) -> EventLoopFuture<Channel> {
445+
return NIOClientTCPBootstrap.makeHTTP1Channel(destination: self.key,
446+
eventLoop: self.eventLoop,
447+
configuration: self.configuration,
448+
sslContextCache: self.pool.sslContextCache,
449+
preference: preference,
450+
logger: logger)
439451
}
440452

441453
/// A `Waiter` represents a request that waits for a connection when none is

Diff for: Sources/AsyncHTTPClient/HTTPClient.swift

+5-4
Original file line numberDiff line numberDiff line change
@@ -900,7 +900,9 @@ extension ChannelPipeline {
900900
try sync.addHandler(handler)
901901
}
902902

903-
func syncAddLateSSLHandlerIfNeeded(for key: ConnectionPool.Key, tlsConfiguration: TLSConfiguration?, handshakePromise: EventLoopPromise<Void>) {
903+
func syncAddLateSSLHandlerIfNeeded(for key: ConnectionPool.Key,
904+
sslContext: NIOSSLContext,
905+
handshakePromise: EventLoopPromise<Void>) {
904906
precondition(key.scheme.requiresTLS)
905907

906908
do {
@@ -913,10 +915,9 @@ extension ChannelPipeline {
913915
try synchronousPipelineView.addHandler(eventsHandler, name: TLSEventsHandler.handlerName)
914916

915917
// Then we add the SSL handler.
916-
let tlsConfiguration = tlsConfiguration ?? TLSConfiguration.forClient()
917-
let context = try NIOSSLContext(configuration: tlsConfiguration)
918918
try synchronousPipelineView.addHandler(
919-
try NIOSSLClientHandler(context: context, serverHostname: (key.host.isIPAddress || key.host.isEmpty) ? nil : key.host),
919+
try NIOSSLClientHandler(context: sslContext,
920+
serverHostname: (key.host.isIPAddress || key.host.isEmpty) ? nil : key.host),
920921
position: .before(eventsHandler)
921922
)
922923
} catch {

Diff for: Sources/AsyncHTTPClient/LRUCache.swift

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the AsyncHTTPClient open source project
4+
//
5+
// Copyright (c) 2021 Apple Inc. and the AsyncHTTPClient project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
struct LRUCache<Key: Equatable, Value> {
16+
private typealias Generation = UInt64
17+
private struct Element {
18+
var generation: Generation
19+
var key: Key
20+
var value: Value
21+
}
22+
23+
private let capacity: Int
24+
private var generation: Generation = 0
25+
private var elements: [Element]
26+
27+
init(capacity: Int = 8) {
28+
precondition(capacity > 0, "capacity needs to be > 0")
29+
self.capacity = capacity
30+
self.elements = []
31+
self.elements.reserveCapacity(capacity)
32+
}
33+
34+
private mutating func bumpGenerationAndFindIndex(key: Key) -> Int? {
35+
self.generation += 1
36+
37+
let found = self.elements.firstIndex { element in
38+
element.key == key
39+
}
40+
41+
return found
42+
}
43+
44+
mutating func find(key: Key) -> Value? {
45+
if let found = self.bumpGenerationAndFindIndex(key: key) {
46+
self.elements[found].generation = self.generation
47+
return self.elements[found].value
48+
} else {
49+
return nil
50+
}
51+
}
52+
53+
@discardableResult
54+
mutating func append(key: Key, value: Value) -> Value {
55+
let newElement = Element(generation: self.generation,
56+
key: key,
57+
value: value)
58+
if let found = self.bumpGenerationAndFindIndex(key: key) {
59+
self.elements[found] = newElement
60+
return value
61+
}
62+
63+
if self.elements.count < self.capacity {
64+
self.elements.append(newElement)
65+
return value
66+
}
67+
assert(self.elements.count == self.capacity)
68+
assert(self.elements.count > 0)
69+
70+
let minIndex = self.elements.minIndex { l, r in
71+
l.generation < r.generation
72+
}!
73+
74+
self.elements.swapAt(minIndex, self.elements.endIndex - 1)
75+
self.elements.removeLast()
76+
self.elements.append(newElement)
77+
78+
return value
79+
}
80+
81+
mutating func findOrAppend(key: Key, _ valueGenerator: (Key) -> Value) -> Value {
82+
if let found = self.find(key: key) {
83+
return found
84+
}
85+
86+
return self.append(key: key, value: valueGenerator(key))
87+
}
88+
}
89+
90+
extension Array {
91+
func minIndex(by areInIncreasingOrder: (Element, Element) throws -> Bool) rethrows -> Index? {
92+
guard var minSoFar: (Index, Element) = self.first.map({ (0, $0) }) else {
93+
return nil
94+
}
95+
96+
for indexElement in self.enumerated() {
97+
if try areInIncreasingOrder(indexElement.1, minSoFar.1) {
98+
minSoFar = indexElement
99+
}
100+
}
101+
102+
return minSoFar.0
103+
}
104+
}

Diff for: Sources/AsyncHTTPClient/NIOTransportServices/NWErrorHandler.swift

+31-23
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,14 @@
1313
//===----------------------------------------------------------------------===//
1414

1515
#if canImport(Network)
16-
1716
import Network
18-
import NIO
19-
import NIOHTTP1
20-
import NIOTransportServices
17+
#endif
18+
import NIO
19+
import NIOHTTP1
20+
import NIOTransportServices
2121

22-
extension HTTPClient {
22+
extension HTTPClient {
23+
#if canImport(Network)
2324
public struct NWPOSIXError: Error, CustomStringConvertible {
2425
/// POSIX error code (enum)
2526
public let errorCode: POSIXErrorCode
@@ -57,28 +58,35 @@
5758

5859
public var description: String { return self.reason }
5960
}
61+
#endif
6062

61-
@available(macOS 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, *)
62-
class NWErrorHandler: ChannelInboundHandler {
63-
typealias InboundIn = HTTPClientResponsePart
63+
class NWErrorHandler: ChannelInboundHandler {
64+
typealias InboundIn = HTTPClientResponsePart
6465

65-
func errorCaught(context: ChannelHandlerContext, error: Error) {
66-
context.fireErrorCaught(NWErrorHandler.translateError(error))
67-
}
66+
func errorCaught(context: ChannelHandlerContext, error: Error) {
67+
context.fireErrorCaught(NWErrorHandler.translateError(error))
68+
}
6869

69-
static func translateError(_ error: Error) -> Error {
70-
if let error = error as? NWError {
71-
switch error {
72-
case .tls(let status):
73-
return NWTLSError(status, reason: error.localizedDescription)
74-
case .posix(let errorCode):
75-
return NWPOSIXError(errorCode, reason: error.localizedDescription)
76-
default:
77-
return error
70+
static func translateError(_ error: Error) -> Error {
71+
#if canImport(Network)
72+
if #available(OSX 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, *) {
73+
if let error = error as? NWError {
74+
switch error {
75+
case .tls(let status):
76+
return NWTLSError(status, reason: error.localizedDescription)
77+
case .posix(let errorCode):
78+
return NWPOSIXError(errorCode, reason: error.localizedDescription)
79+
default:
80+
return error
81+
}
7882
}
83+
return error
84+
} else {
85+
preconditionFailure("\(self) used on a non-NIOTS Channel")
7986
}
80-
return error
81-
}
87+
#else
88+
preconditionFailure("\(self) used on a non-NIOTS Channel")
89+
#endif
8290
}
8391
}
84-
#endif
92+
}

Diff for: Sources/AsyncHTTPClient/SSLContextCache.swift

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the AsyncHTTPClient open source project
4+
//
5+
// Copyright (c) 2021 Apple Inc. and the AsyncHTTPClient project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import Logging
16+
import NIO
17+
import NIOConcurrencyHelpers
18+
import NIOSSL
19+
20+
class SSLContextCache {
21+
private var state = State.activeNoThread
22+
private let lock = Lock()
23+
private var sslContextCache = LRUCache<BestEffortHashableTLSConfiguration, NIOSSLContext>()
24+
private let threadPool = NIOThreadPool(numberOfThreads: 1)
25+
26+
enum State {
27+
case activeNoThread
28+
case active
29+
case shutDown
30+
}
31+
32+
init() {}
33+
34+
func shutdown() {
35+
self.lock.withLock { () -> Void in
36+
switch self.state {
37+
case .activeNoThread:
38+
self.state = .shutDown
39+
case .active:
40+
self.state = .shutDown
41+
self.threadPool.shutdownGracefully { maybeError in
42+
precondition(maybeError == nil, "\(maybeError!)")
43+
}
44+
case .shutDown:
45+
preconditionFailure("SSLContextCache shut down twice")
46+
}
47+
}
48+
}
49+
50+
deinit {
51+
assert(self.state == .shutDown)
52+
}
53+
}
54+
55+
extension SSLContextCache {
56+
private struct SSLContextCacheShutdownError: Error {}
57+
58+
func sslContext(tlsConfiguration: TLSConfiguration,
59+
eventLoop: EventLoop,
60+
logger: Logger) -> EventLoopFuture<NIOSSLContext> {
61+
let earlyExitError: Error? = self.lock.withLock { () -> Error? in
62+
switch self.state {
63+
case .activeNoThread:
64+
self.state = .active
65+
self.threadPool.start()
66+
return nil
67+
case .active:
68+
return nil
69+
case .shutDown:
70+
return SSLContextCacheShutdownError()
71+
}
72+
}
73+
74+
if let error = earlyExitError {
75+
return eventLoop.makeFailedFuture(error)
76+
}
77+
78+
let eqTLSConfiguration = BestEffortHashableTLSConfiguration(wrapping: tlsConfiguration)
79+
let sslContext = self.lock.withLock {
80+
self.sslContextCache.find(key: eqTLSConfiguration)
81+
}
82+
83+
if let sslContext = sslContext {
84+
logger.debug("found SSL context in cache",
85+
metadata: ["ahc-tls-config": "\(tlsConfiguration)"])
86+
return eventLoop.makeSucceededFuture(sslContext)
87+
}
88+
89+
logger.debug("creating new SSL context",
90+
metadata: ["ahc-tls-config": "\(tlsConfiguration)"])
91+
let newSSLContext = self.threadPool.runIfActive(eventLoop: eventLoop) {
92+
try NIOSSLContext(configuration: tlsConfiguration)
93+
}
94+
95+
newSSLContext.whenSuccess { (newSSLContext: NIOSSLContext) -> Void in
96+
self.lock.withLock { () -> Void in
97+
self.sslContextCache.append(key: eqTLSConfiguration,
98+
value: newSSLContext)
99+
}
100+
}
101+
102+
return newSSLContext
103+
}
104+
}

0 commit comments

Comments
 (0)