Skip to content

Commit c6bfa23

Browse files
committed
cache NIOSSLContext (saves 27k allocs per conn)
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 c6bfa23

File tree

6 files changed

+237
-19
lines changed

6 files changed

+237
-19
lines changed

Sources/AsyncHTTPClient/ConnectionPool.swift

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import NIOHTTP1
2020
import NIOHTTPCompression
2121
import NIOTLS
2222
import NIOTransportServices
23+
import NIOSSL
2324

2425
/// A connection pool that manages and creates new connections to hosts respecting the specified preferences
2526
///
@@ -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

@@ -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

Sources/AsyncHTTPClient/HTTPClient.swift

Lines changed: 5 additions & 4 deletions
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 {
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
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 & Hashable,
16+
Value> {
17+
private typealias Generation = UInt64
18+
private struct Element {
19+
var generation: Generation
20+
var key: Key
21+
var value: Value
22+
}
23+
24+
private var generation: Generation = 0
25+
private var elements: [Element]
26+
27+
init(capacity: Int = 8) {
28+
self.elements = []
29+
self.elements.reserveCapacity(capacity)
30+
}
31+
32+
private mutating func findIndex(key: Key) -> Int? {
33+
self.generation += 1
34+
35+
let found = self.elements.firstIndex { element in
36+
element.key == key
37+
}
38+
39+
return found
40+
}
41+
42+
mutating func find(key: Key) -> Value? {
43+
if let found = self.findIndex(key: key) {
44+
self.elements[found].generation = self.generation
45+
return self.elements[found].value
46+
} else {
47+
return nil
48+
}
49+
}
50+
51+
@discardableResult
52+
mutating func append(key: Key, value: Value) -> Value {
53+
let newElement = Element(generation: self.generation,
54+
key: key,
55+
value: value)
56+
if let found = self.findIndex(key: key) {
57+
self.elements[found] = newElement
58+
return value
59+
}
60+
61+
if self.elements.count < self.elements.capacity {
62+
self.elements.append(newElement)
63+
return value
64+
}
65+
assert(self.elements.count == self.elements.capacity)
66+
67+
let minIndex = self.elements.minIndex { l, r in
68+
l.generation < r.generation
69+
}!
70+
71+
self.elements.swapAt(minIndex, self.elements.endIndex - 1)
72+
self.elements.removeLast()
73+
self.elements.append(newElement)
74+
75+
return value
76+
}
77+
78+
mutating func findOrAppend(key: Key, _ valueGenerator: (Key) -> Value) -> Value {
79+
if let found = self.find(key: key) {
80+
return found
81+
}
82+
83+
return self.append(key: key, value: valueGenerator(key))
84+
}
85+
}
86+
87+
extension Array {
88+
func minIndex(by areInIncreasingOrder: (Element, Element) throws -> Bool) rethrows -> Index? {
89+
var minSoFar: (Index, Element)? = nil
90+
91+
for indexElement in self.enumerated() {
92+
if let min = minSoFar {
93+
if try areInIncreasingOrder(indexElement.1, min.1) {
94+
minSoFar = indexElement
95+
}
96+
}
97+
}
98+
99+
return minSoFar.map { $0.0 }
100+
}
101+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the AsyncHTTPClient open source project
4+
//
5+
// Copyright (c) 2018-2019 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 NIOSSL
18+
import NIOConcurrencyHelpers
19+
20+
class SSLContextCache {
21+
private var running = true
22+
private let lock = Lock()
23+
private var sslContextCache = LRUCache<BestEffortHashableTLSConfiguration, NIOSSLContext>()
24+
private let threadPool = NIOThreadPool(numberOfThreads: 1)
25+
26+
init() {
27+
self.threadPool.start()
28+
}
29+
30+
func shutdown() {
31+
self.lock.withLock {
32+
precondition(self.running == true)
33+
self.running = false
34+
self.threadPool.shutdownGracefully { maybeError in
35+
precondition(maybeError == nil, "\(maybeError!)")
36+
}
37+
}
38+
}
39+
40+
deinit {
41+
assert(!self.running)
42+
}
43+
}
44+
45+
extension SSLContextCache {
46+
func sslContext(tlsConfiguration: TLSConfiguration,
47+
eventLoop: EventLoop,
48+
logger: Logger) -> EventLoopFuture<NIOSSLContext> {
49+
let eqTLSConfiguration = BestEffortHashableTLSConfiguration(wrapping: tlsConfiguration)
50+
let sslContext = self.lock.withLock {
51+
self.sslContextCache.find(key: eqTLSConfiguration)
52+
}
53+
54+
if let sslContext = sslContext {
55+
logger.debug("found SSL context in cache",
56+
metadata: ["ahc-tls-config": "\(tlsConfiguration)"])
57+
return eventLoop.makeSucceededFuture(sslContext)
58+
}
59+
60+
logger.debug("creating new SSL context",
61+
metadata: ["ahc-tls-config": "\(tlsConfiguration)"])
62+
let newSSLContext = self.threadPool.runIfActive(eventLoop: eventLoop) {
63+
return try NIOSSLContext(configuration: tlsConfiguration)
64+
}
65+
66+
newSSLContext.whenSuccess { (newSSLContext: NIOSSLContext) -> Void in
67+
self.lock.withLock { () -> Void in
68+
self.sslContextCache.append(key: eqTLSConfiguration,
69+
value: newSSLContext)
70+
}
71+
}
72+
73+
return newSSLContext
74+
}
75+
}
76+

Sources/AsyncHTTPClient/Utils.swift

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import NIOHTTP1
2121
import NIOHTTPCompression
2222
import NIOSSL
2323
import NIOTransportServices
24+
import Logging
2425

2526
internal extension String {
2627
var isIPAddress: Bool {
@@ -144,7 +145,12 @@ extension NIOClientTCPBootstrap {
144145
}
145146
}
146147

147-
static func makeHTTP1Channel(destination: ConnectionPool.Key, eventLoop: EventLoop, configuration: HTTPClient.Configuration, preference: HTTPClient.EventLoopPreference) -> EventLoopFuture<Channel> {
148+
static func makeHTTP1Channel(destination: ConnectionPool.Key,
149+
eventLoop: EventLoop,
150+
configuration: HTTPClient.Configuration,
151+
sslContextCache: SSLContextCache,
152+
preference: HTTPClient.EventLoopPreference,
153+
logger: Logger) -> EventLoopFuture<Channel> {
148154
let channelEventLoop = preference.bestEventLoop ?? eventLoop
149155

150156
let key = destination
@@ -166,14 +172,29 @@ extension NIOClientTCPBootstrap {
166172
channel = bootstrap.connect(unixDomainSocketPath: key.unixPath)
167173
}
168174

175+
let requiresLateSSLHandler = configuration.proxy != nil && requiresTLS
176+
let sslContext: EventLoopFuture<NIOSSLContext?>
177+
if key.scheme.requiresTLS || requiresLateSSLHandler {
178+
sslContext = sslContextCache.sslContext(tlsConfiguration: destination.tlsConfiguration?.base ?? .forClient(),
179+
eventLoop: eventLoop,
180+
logger: logger).map { $0 }
181+
} else {
182+
sslContext = eventLoop.makeSucceededFuture(nil)
183+
}
184+
169185
return channel.flatMap { channel in
186+
sslContext.map { sslContext in
187+
(channel, sslContext)
188+
}
189+
}.flatMap { (channel, sslContext) in
170190
let requiresTLS = key.scheme.requiresTLS
171-
let requiresLateSSLHandler = configuration.proxy != nil && requiresTLS
172191
let handshakeFuture: EventLoopFuture<Void>
173192

174193
if requiresLateSSLHandler {
175194
let handshakePromise = channel.eventLoop.makePromise(of: Void.self)
176-
channel.pipeline.syncAddLateSSLHandlerIfNeeded(for: key, tlsConfiguration: configuration.tlsConfiguration, handshakePromise: handshakePromise)
195+
channel.pipeline.syncAddLateSSLHandlerIfNeeded(for: key,
196+
sslContext: sslContext!,
197+
handshakePromise: handshakePromise)
177198
handshakeFuture = handshakePromise.futureResult
178199
} else if requiresTLS {
179200
do {

Tests/AsyncHTTPClientTests/ConnectionTests.swift

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import XCTest
1919
class ConnectionTests: XCTestCase {
2020
var eventLoop: EmbeddedEventLoop!
2121
var http1ConnectionProvider: HTTP1ConnectionProvider!
22+
var pool: ConnectionPool!
2223

2324
func buildState(connection: Connection, release: Bool) {
2425
XCTAssertTrue(self.http1ConnectionProvider.state.enqueue())
@@ -131,24 +132,30 @@ class ConnectionTests: XCTestCase {
131132
}
132133

133134
override func setUp() {
135+
XCTAssertNil(self.pool)
134136
XCTAssertNil(self.eventLoop)
135137
XCTAssertNil(self.http1ConnectionProvider)
136138
self.eventLoop = EmbeddedEventLoop()
137-
XCTAssertNoThrow(self.http1ConnectionProvider = try HTTP1ConnectionProvider(key: .init(.init(url: "http://some.test")),
138-
eventLoop: self.eventLoop,
139-
configuration: .init(),
140-
pool: .init(configuration: .init(),
141-
backgroundActivityLogger: HTTPClient.loggingDisabled),
142-
backgroundActivityLogger: HTTPClient.loggingDisabled))
139+
self.pool = ConnectionPool(configuration: .init(),
140+
backgroundActivityLogger: HTTPClient.loggingDisabled)
141+
XCTAssertNoThrow(self.http1ConnectionProvider =
142+
try HTTP1ConnectionProvider(key: .init(.init(url: "http://some.test")),
143+
eventLoop: self.eventLoop,
144+
configuration: .init(),
145+
pool: self.pool,
146+
backgroundActivityLogger: HTTPClient.loggingDisabled))
143147
}
144148

145149
override func tearDown() {
150+
XCTAssertNotNil(self.pool)
146151
XCTAssertNotNil(self.eventLoop)
147152
XCTAssertNotNil(self.http1ConnectionProvider)
148153
XCTAssertNoThrow(try self.http1ConnectionProvider.close().wait())
149154
XCTAssertNoThrow(try self.eventLoop.syncShutdownGracefully())
150-
self.eventLoop = nil
151155
self.http1ConnectionProvider = nil
156+
XCTAssertTrue(try self.pool.close(on: self.eventLoop).wait())
157+
self.eventLoop = nil
158+
self.pool = nil
152159
}
153160
}
154161

0 commit comments

Comments
 (0)