Skip to content

Commit 539671d

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 539671d

File tree

13 files changed

+684
-147
lines changed

13 files changed

+684
-147
lines changed

‎Sources/AsyncHTTPClient/ConnectionPool.swift‎

Lines changed: 18 additions & 6 deletions
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
privateletbackgroundActivityLogger:Logger
4344

45+
letsslContextCache=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
returnEventLoopFuture.reduce(true, providers.map{ $0.close()}, on: eventLoop){ $0 && $1 }
110115
}
111116

@@ -148,7 +153,7 @@ final class ConnectionPool{
148153
varhost:String
149154
varport:Int
150155
varunixPath:String
151-
vartlsConfiguration:BestEffortHashableTLSConfiguration?
156+
privatevartlsConfiguration:BestEffortHashableTLSConfiguration?
152157

153158
enumScheme: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
returnself.closePromise.futureResult.map{true}
435441
}
436442

437-
privatefunc makeChannel(preference:HTTPClient.EventLoopPreference)->EventLoopFuture<Channel>{
438-
returnNIOClientTCPBootstrap.makeHTTP1Channel(destination:self.key, eventLoop:self.eventLoop, configuration:self.configuration, preference: preference)
443+
privatefunc makeChannel(preference:HTTPClient.EventLoopPreference,
444+
logger:Logger)->EventLoopFuture<Channel>{
445+
returnNIOClientTCPBootstrap.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-
lettlsConfiguration= tlsConfiguration ??TLSConfiguration.forClient()
917-
letcontext=tryNIOSSLContext(configuration: tlsConfiguration)
918918
try synchronousPipelineView.addHandler(
919-
tryNIOSSLClientHandler(context: context, serverHostname:(key.host.isIPAddress || key.host.isEmpty)?nil: key.host),
919+
tryNIOSSLClientHandler(context: sslContext,
920+
serverHostname:(key.host.isIPAddress || key.host.isEmpty)?nil: key.host),
920921
position:.before(eventsHandler)
921922
)
922923
}catch{
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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+
structLRUCache<Key:Equatable&Hashable,
16+
Value>{
17+
privatetypealiasGeneration=UInt64
18+
privatestructElement{
19+
vargeneration:Generation
20+
varkey:Key
21+
varvalue:Value
22+
}
23+
24+
privateletcapacity:Int
25+
privatevargeneration:Generation=0
26+
privatevarelements:[Element]
27+
28+
init(capacity:Int=8){
29+
precondition(capacity >0,"capacity needs to be > 0")
30+
self.capacity = capacity
31+
self.elements =[]
32+
self.elements.reserveCapacity(capacity)
33+
}
34+
35+
privatemutatingfunc findIndex(key:Key)->Int?{
36+
self.generation +=1
37+
38+
letfound=self.elements.firstIndex{ element in
39+
element.key == key
40+
}
41+
42+
return found
43+
}
44+
45+
mutatingfunc find(key:Key)->Value?{
46+
iflet found =self.findIndex(key: key){
47+
self.elements[found].generation =self.generation
48+
returnself.elements[found].value
49+
}else{
50+
returnnil
51+
}
52+
}
53+
54+
@discardableResult
55+
mutatingfunc append(key:Key, value:Value)->Value{
56+
letnewElement=Element(generation:self.generation,
57+
key: key,
58+
value: value)
59+
iflet found =self.findIndex(key: key){
60+
self.elements[found]= newElement
61+
return value
62+
}
63+
64+
ifself.elements.count <self.capacity {
65+
self.elements.append(newElement)
66+
return value
67+
}
68+
assert(self.elements.count ==self.capacity)
69+
assert(self.elements.count >0)
70+
71+
letminIndex=self.elements.minIndex{ l, r in
72+
l.generation < r.generation
73+
}!
74+
75+
self.elements.swapAt(minIndex,self.elements.endIndex -1)
76+
self.elements.removeLast()
77+
self.elements.append(newElement)
78+
79+
return value
80+
}
81+
82+
mutatingfunc findOrAppend(key:Key, _ valueGenerator:(Key)->Value)->Value{
83+
iflet found =self.find(key: key){
84+
return found
85+
}
86+
87+
returnself.append(key: key, value:valueGenerator(key))
88+
}
89+
}
90+
91+
extensionArray{
92+
func minIndex(by areInIncreasingOrder:(Element,Element)throws->Bool)rethrows->Index?{
93+
varminSoFar:(Index,Element)?
94+
95+
forindexElementinself.enumerated(){
96+
iflet min = minSoFar {
97+
iftryareInIncreasingOrder(indexElement.1, min.1){
98+
minSoFar = indexElement
99+
}
100+
}else{
101+
minSoFar = indexElement
102+
}
103+
}
104+
105+
return minSoFar.map{ $0.0}
106+
}
107+
}

‎Sources/AsyncHTTPClient/NIOTransportServices/NWErrorHandler.swift‎

Lines changed: 31 additions & 23 deletions
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-
extensionHTTPClient{
22+
extensionHTTPClient{
23+
#if canImport(Network)
2324
publicstructNWPOSIXError:Error,CustomStringConvertible{
2425
/// POSIX error code (enum)
2526
publicleterrorCode:POSIXErrorCode
@@ -57,28 +58,35 @@
5758

5859
publicvardescription:String{returnself.reason }
5960
}
61+
#endif
6062

61-
@available(macOS 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0,*)
62-
classNWErrorHandler:ChannelInboundHandler{
63-
typealiasInboundIn=HTTPClientResponsePart
63+
classNWErrorHandler:ChannelInboundHandler{
64+
typealiasInboundIn=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-
staticfunc translateError(_ error:Error)->Error{
70-
iflet error = error as?NWError{
71-
switch error {
72-
case.tls(let status):
73-
returnNWTLSError(status, reason: error.localizedDescription)
74-
case.posix(let errorCode):
75-
returnNWPOSIXError(errorCode, reason: error.localizedDescription)
76-
default:
77-
return error
70+
staticfunc translateError(_ error:Error)->Error{
71+
#if canImport(Network)
72+
if #available(OSX 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0,*){
73+
iflet error = error as?NWError{
74+
switch error {
75+
case.tls(let status):
76+
returnNWTLSError(status, reason: error.localizedDescription)
77+
case.posix(let errorCode):
78+
returnNWPOSIXError(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+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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+
classSSLContextCache{
21+
privatevarstate=State.activeNoThread
22+
privateletlock=Lock()
23+
privatevarsslContextCache=LRUCache<BestEffortHashableTLSConfiguration,NIOSSLContext>()
24+
privateletthreadPool=NIOThreadPool(numberOfThreads:1)
25+
26+
enumState{
27+
case activeNoThread
28+
case active
29+
case shutDown
30+
}
31+
32+
init(){}
33+
34+
func shutdown(){
35+
self.lock.withLock{()->Voidin
36+
switchself.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+
extensionSSLContextCache{
56+
func sslContext(tlsConfiguration:TLSConfiguration,
57+
eventLoop:EventLoop,
58+
logger:Logger)->EventLoopFuture<NIOSSLContext>{
59+
letearlyExit=self.lock.withLock{()->EventLoopFuture<NIOSSLContext>?in
60+
switchself.state {
61+
case.activeNoThread:
62+
self.state =.active
63+
self.threadPool.start()
64+
returnnil
65+
case.active:
66+
returnnil
67+
case.shutDown:
68+
structSSLContextCacheShutdownError:Error{}
69+
return eventLoop.makeFailedFuture(SSLContextCacheShutdownError())
70+
}
71+
}
72+
guard earlyExit ==nilelse{
73+
return earlyExit!
74+
}
75+
76+
leteqTLSConfiguration=BestEffortHashableTLSConfiguration(wrapping: tlsConfiguration)
77+
letsslContext=self.lock.withLock{
78+
self.sslContextCache.find(key: eqTLSConfiguration)
79+
}
80+
81+
iflet sslContext = sslContext {
82+
logger.debug("found SSL context in cache",
83+
metadata:["ahc-tls-config":"\(tlsConfiguration)"])
84+
return eventLoop.makeSucceededFuture(sslContext)
85+
}
86+
87+
logger.debug("creating new SSL context",
88+
metadata:["ahc-tls-config":"\(tlsConfiguration)"])
89+
letnewSSLContext=self.threadPool.runIfActive(eventLoop: eventLoop){
90+
tryNIOSSLContext(configuration: tlsConfiguration)
91+
}
92+
93+
newSSLContext.whenSuccess{(newSSLContext:NIOSSLContext)->Voidin
94+
self.lock.withLock{()->Voidin
95+
self.sslContextCache.append(key: eqTLSConfiguration,
96+
value: newSSLContext)
97+
}
98+
}
99+
100+
return newSSLContext
101+
}
102+
}

0 commit comments

Comments
(0)