Skip to content

cache NIOSSLContext (saves 27k allocs per conn) #362

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 13, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions Sources/AsyncHTTPClient/ConnectionPool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import NIO
import NIOConcurrencyHelpers
import NIOHTTP1
import NIOHTTPCompression
import NIOSSL
import NIOTLS
import NIOTransportServices

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

private let backgroundActivityLogger: Logger

let sslContextCache = SSLContextCache()

init(configuration: HTTPClient.Configuration, backgroundActivityLogger: Logger) {
self.configuration = configuration
self.backgroundActivityLogger = backgroundActivityLogger
Expand Down Expand Up @@ -106,6 +109,8 @@ final class ConnectionPool {
self.providers.values
}

self.sslContextCache.shutdown()

return EventLoopFuture.reduce(true, providers.map { $0.close() }, on: eventLoop) { $0 && $1 }
}

Expand Down Expand Up @@ -148,7 +153,7 @@ final class ConnectionPool {
var host: String
var port: Int
var unixPath: String
var tlsConfiguration: BestEffortHashableTLSConfiguration?
private var tlsConfiguration: BestEffortHashableTLSConfiguration?
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: make this private because nobody accesses it from the outside and if not private, then there are places where we have both a key.tlsConfiguration and a configuration.tlsConfiguration which aren't necessarily the same (eg. when doing https:// over a http:// proxy).


enum Scheme: Hashable {
case http
Expand Down Expand Up @@ -249,14 +254,15 @@ class HTTP1ConnectionProvider {
} else {
logger.trace("opening fresh connection (found matching but inactive connection)",
metadata: ["ahc-dead-connection": "\(connection)"])
self.makeChannel(preference: waiter.preference).whenComplete { result in
self.makeChannel(preference: waiter.preference,
logger: logger).whenComplete { result in
self.connect(result, waiter: waiter, logger: logger)
}
}
}
case .create(let waiter):
logger.trace("opening fresh connection (no connections to reuse available)")
self.makeChannel(preference: waiter.preference).whenComplete { result in
self.makeChannel(preference: waiter.preference, logger: logger).whenComplete { result in
self.connect(result, waiter: waiter, logger: logger)
}
case .replace(let connection, let waiter):
Expand All @@ -266,7 +272,7 @@ class HTTP1ConnectionProvider {
logger.trace("opening fresh connection (replacing exising connection)",
metadata: ["ahc-old-connection": "\(connection)",
"ahc-waiter": "\(waiter)"])
self.makeChannel(preference: waiter.preference).whenComplete { result in
self.makeChannel(preference: waiter.preference, logger: logger).whenComplete { result in
self.connect(result, waiter: waiter, logger: logger)
}
}
Expand Down Expand Up @@ -434,8 +440,14 @@ class HTTP1ConnectionProvider {
return self.closePromise.futureResult.map { true }
}

private func makeChannel(preference: HTTPClient.EventLoopPreference) -> EventLoopFuture<Channel> {
return NIOClientTCPBootstrap.makeHTTP1Channel(destination: self.key, eventLoop: self.eventLoop, configuration: self.configuration, preference: preference)
private func makeChannel(preference: HTTPClient.EventLoopPreference,
logger: Logger) -> EventLoopFuture<Channel> {
return NIOClientTCPBootstrap.makeHTTP1Channel(destination: self.key,
eventLoop: self.eventLoop,
configuration: self.configuration,
sslContextCache: self.pool.sslContextCache,
preference: preference,
logger: logger)
}

/// A `Waiter` represents a request that waits for a connection when none is
Expand Down
9 changes: 5 additions & 4 deletions Sources/AsyncHTTPClient/HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -900,7 +900,9 @@ extension ChannelPipeline {
try sync.addHandler(handler)
}

func syncAddLateSSLHandlerIfNeeded(for key: ConnectionPool.Key, tlsConfiguration: TLSConfiguration?, handshakePromise: EventLoopPromise<Void>) {
func syncAddLateSSLHandlerIfNeeded(for key: ConnectionPool.Key,
sslContext: NIOSSLContext,
handshakePromise: EventLoopPromise<Void>) {
precondition(key.scheme.requiresTLS)

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

// Then we add the SSL handler.
let tlsConfiguration = tlsConfiguration ?? TLSConfiguration.forClient()
let context = try NIOSSLContext(configuration: tlsConfiguration)
try synchronousPipelineView.addHandler(
try NIOSSLClientHandler(context: context, serverHostname: (key.host.isIPAddress || key.host.isEmpty) ? nil : key.host),
try NIOSSLClientHandler(context: sslContext,
serverHostname: (key.host.isIPAddress || key.host.isEmpty) ? nil : key.host),
position: .before(eventsHandler)
)
} catch {
Expand Down
104 changes: 104 additions & 0 deletions Sources/AsyncHTTPClient/LRUCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the AsyncHTTPClient open source project
//
// Copyright (c) 2021 Apple Inc. and the AsyncHTTPClient project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

struct LRUCache<Key: Equatable, Value> {
private typealias Generation = UInt64
private struct Element {
var generation: Generation
var key: Key
var value: Value
}

private let capacity: Int
private var generation: Generation = 0
private var elements: [Element]

init(capacity: Int = 8) {
precondition(capacity > 0, "capacity needs to be > 0")
self.capacity = capacity
self.elements = []
self.elements.reserveCapacity(capacity)
}

private mutating func bumpGenerationAndFindIndex(key: Key) -> Int? {
self.generation += 1

let found = self.elements.firstIndex { element in
element.key == key
}

return found
}

mutating func find(key: Key) -> Value? {
if let found = self.bumpGenerationAndFindIndex(key: key) {
self.elements[found].generation = self.generation
return self.elements[found].value
} else {
return nil
}
}

@discardableResult
mutating func append(key: Key, value: Value) -> Value {
let newElement = Element(generation: self.generation,
key: key,
value: value)
if let found = self.bumpGenerationAndFindIndex(key: key) {
self.elements[found] = newElement
return value
}

if self.elements.count < self.capacity {
self.elements.append(newElement)
return value
}
assert(self.elements.count == self.capacity)
assert(self.elements.count > 0)

let minIndex = self.elements.minIndex { l, r in
l.generation < r.generation
}!

self.elements.swapAt(minIndex, self.elements.endIndex - 1)
self.elements.removeLast()
self.elements.append(newElement)

return value
}

mutating func findOrAppend(key: Key, _ valueGenerator: (Key) -> Value) -> Value {
if let found = self.find(key: key) {
return found
}

return self.append(key: key, value: valueGenerator(key))
}
}

extension Array {
func minIndex(by areInIncreasingOrder: (Element, Element) throws -> Bool) rethrows -> Index? {
guard var minSoFar: (Index, Element) = self.first.map({ (0, $0) }) else {
return nil
}

for indexElement in self.enumerated() {
if try areInIncreasingOrder(indexElement.1, minSoFar.1) {
minSoFar = indexElement
}
}

return minSoFar.0
}
}
54 changes: 31 additions & 23 deletions Sources/AsyncHTTPClient/NIOTransportServices/NWErrorHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@
//===----------------------------------------------------------------------===//

#if canImport(Network)

import Network
import NIO
import NIOHTTP1
import NIOTransportServices
#endif
import NIO
import NIOHTTP1
import NIOTransportServices

extension HTTPClient {
extension HTTPClient {
#if canImport(Network)
public struct NWPOSIXError: Error, CustomStringConvertible {
/// POSIX error code (enum)
public let errorCode: POSIXErrorCode
Expand Down Expand Up @@ -57,28 +58,35 @@

public var description: String { return self.reason }
}
#endif

@available(macOS 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, *)
class NWErrorHandler: ChannelInboundHandler {
typealias InboundIn = HTTPClientResponsePart
class NWErrorHandler: ChannelInboundHandler {
typealias InboundIn = HTTPClientResponsePart

func errorCaught(context: ChannelHandlerContext, error: Error) {
context.fireErrorCaught(NWErrorHandler.translateError(error))
}
func errorCaught(context: ChannelHandlerContext, error: Error) {
context.fireErrorCaught(NWErrorHandler.translateError(error))
}

static func translateError(_ error: Error) -> Error {
if let error = error as? NWError {
switch error {
case .tls(let status):
return NWTLSError(status, reason: error.localizedDescription)
case .posix(let errorCode):
return NWPOSIXError(errorCode, reason: error.localizedDescription)
default:
return error
static func translateError(_ error: Error) -> Error {
#if canImport(Network)
if #available(OSX 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, *) {
if let error = error as? NWError {
switch error {
case .tls(let status):
return NWTLSError(status, reason: error.localizedDescription)
case .posix(let errorCode):
return NWPOSIXError(errorCode, reason: error.localizedDescription)
default:
return error
}
}
return error
} else {
preconditionFailure("\(self) used on a non-NIOTS Channel")
}
return error
}
#else
preconditionFailure("\(self) used on a non-NIOTS Channel")
#endif
}
}
#endif
}
104 changes: 104 additions & 0 deletions Sources/AsyncHTTPClient/SSLContextCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the AsyncHTTPClient open source project
//
// Copyright (c) 2021 Apple Inc. and the AsyncHTTPClient project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import Logging
import NIO
import NIOConcurrencyHelpers
import NIOSSL

class SSLContextCache {
private var state = State.activeNoThread
private let lock = Lock()
private var sslContextCache = LRUCache<BestEffortHashableTLSConfiguration, NIOSSLContext>()
private let threadPool = NIOThreadPool(numberOfThreads: 1)

enum State {
case activeNoThread
case active
case shutDown
}

init() {}

func shutdown() {
self.lock.withLock { () -> Void in
switch self.state {
case .activeNoThread:
self.state = .shutDown
case .active:
self.state = .shutDown
self.threadPool.shutdownGracefully { maybeError in
precondition(maybeError == nil, "\(maybeError!)")
}
case .shutDown:
preconditionFailure("SSLContextCache shut down twice")
}
}
}

deinit {
assert(self.state == .shutDown)
}
}

extension SSLContextCache {
private struct SSLContextCacheShutdownError: Error {}

func sslContext(tlsConfiguration: TLSConfiguration,
eventLoop: EventLoop,
logger: Logger) -> EventLoopFuture<NIOSSLContext> {
let earlyExitError: Error? = self.lock.withLock { () -> Error? in
switch self.state {
case .activeNoThread:
self.state = .active
self.threadPool.start()
return nil
case .active:
return nil
case .shutDown:
return SSLContextCacheShutdownError()
}
}

if let error = earlyExitError {
return eventLoop.makeFailedFuture(error)
}

let eqTLSConfiguration = BestEffortHashableTLSConfiguration(wrapping: tlsConfiguration)
let sslContext = self.lock.withLock {
self.sslContextCache.find(key: eqTLSConfiguration)
}

if let sslContext = sslContext {
logger.debug("found SSL context in cache",
metadata: ["ahc-tls-config": "\(tlsConfiguration)"])
return eventLoop.makeSucceededFuture(sslContext)
}

logger.debug("creating new SSL context",
metadata: ["ahc-tls-config": "\(tlsConfiguration)"])
let newSSLContext = self.threadPool.runIfActive(eventLoop: eventLoop) {
try NIOSSLContext(configuration: tlsConfiguration)
}

newSSLContext.whenSuccess { (newSSLContext: NIOSSLContext) -> Void in
self.lock.withLock { () -> Void in
self.sslContextCache.append(key: eqTLSConfiguration,
value: newSSLContext)
}
}

return newSSLContext
}
}
Loading