Hashable conformance for UnownedTaskExecutor
01 何が問題だったのか
SE-0417 で導入された task executor preference により、withUnsafeCurrentTask を通じて、実行中の Task が現在どのエグゼキュータ上で動いているかを UnownedTaskExecutor として取得できるようになりました。これをキーに「そのエグゼキュータに紐づくリソース」を引くパターンは、コンテキストスイッチを減らしたいパフォーマンス重視のコードで自然に現れます。
典型例はコネクションプールです。ConnectionPool が複数のエグゼキュータにまたがって接続を保持している場合、リクエストしてきた Task と同じエグゼキュータに紐づく接続を優先して返せば、スレッド/エグゼキュータ間の切り替えを避けられます。
ところが、UnownedTaskExecutor は Equatable にしか適合していませんでした。そのため、エグゼキュータをキーに接続を検索しようとすると、線形探索するしかありません。
// 今までは O(n) の線形探索
func pickConnection(preferring executor: UnownedTaskExecutor) -> Connection {
for (e, connection) in executorConnectionList {
if e == executor { return connection }
}
// fallback
return executorConnectionList.first!.connection
}
エグゼキュータをキーにリソースやキャッシュ、スケジューリング用メタデータを索引付けしたいという要求は、コネクションプールに限らず一般的に存在します。これらをまとめて辞書 (Dictionary) や集合 (Set) で扱えるようにするには、UnownedTaskExecutor が Hashable にも適合している必要がありました。
02 どのように解決されるのか
UnownedTaskExecutor に Hashable 適合を追加します。
extension UnownedTaskExecutor: Hashable {}
UnownedTaskExecutor は内部的にエグゼキュータ参照のアイデンティティを表す値を保持した struct で、既存の Equatable 適合もそのアイデンティティに基づいて定義されていました。今回追加される Hashable 適合も同じアイデンティティ値をハッシュに使うため、== と hash(into:) の整合性(a == b なら a.hashValue == b.hashValue)は自然に保たれます。
これにより、先ほどのコネクションプールの例は辞書引きによる定数時間のルックアップで書けるようになります。
// Hashable によって O(1) の辞書引きに
func pickConnection(preferring executor: UnownedTaskExecutor) -> Connection {
if let connection = connectionsByExecutor[executor] {
return connection
}
// fallback
return connectionsByExecutor.values.first!
}
公開 API への変更はこの Hashable 適合の追加のみで、純粋に追加的な変更です。既存のコードはそのままコンパイルでき、Equatable にすでに適合している型が Hashable を取り込むだけなのでソース互換性も保たれます。