A language-agnostic technique for maintaining thread safety — with a statically-verified variant implemented in Swift

Having access to such an instance would then (aptly) serve as proof that the code would run on the main thread.Given such a type, we could architect our code as follows:func stateWasUpdated(mtProof: MainThreadProof) { tableView.reloadData() // must be called on the main thread}func didTapButton(mtProof: MainThreadProof) { state.numberOfTimesButtonWasTapped += 1 stateWasUpdated(mtProof: mtProof)}func receivedWeatherData(latestWeatherData: WeatherData, mtProof: MainThreadProof) { state.weatherData = latestWeatherData stateWasUpdated(mtProof: mtProof)}func receivedWeatherData_wrongAndImpossible( latestWeatherData: WeatherData) { state.weatherData = latestWeatherData stateWasUpdated(mtProof: ???) // we can't call this method!}func receivedWeatherData_async(latestWeatherData: WeatherData) { asynchronouslyPerformOnMainThread { mtProof in // we are guaranteed to be on the main thread, // and so we are given an instance of MainThreadProof receivedWeatherData(latestWeatherData: latestWeatherData, mtProof: mtProof) // all good!.}}The MainThreadProof type would evidently let us build statically-verified main-thread constrained programs.Can we construct such a type?.(spoiler: yes!)1st try (close, but no cigar):We could create the following in an isolated file:public struct MainThreadProof { // instances of `MainThreadProof` can only be created in this file fileprivate init() { }}public func asynchronouslyPerformOnMainThread( work: @escaping (MainThreadProof) -> ()) { DispatchQueue.main.async { let mainThreadProof = MainThreadProof() // ok to create work(mainThreadProof) }}Since we designated its init as fileprivate, instances of the MainThreadProof type can only be created in the above file..And in the above file, instances of the MainThreadProof type are only ever created inside of a DispatchQueue.main.async call.Therefore when you are given an instance of MainThreadProof, you can be sure that somewhere up the call-tree, someone called DispatchQueue.main.async to get the instance given to you.The problem, of course, is that while instances of MainThreadProof can only be created on the main thread, they can also cross thread boundaries.For instance, there is nothing preventing us from doing the following:asynchronouslyPerformOnMainThread { mtProof in backgroundQueue.async { functionWhichMustBeCalledOnTheMainThread(mtProof: mtProof) }}or the following:var globalMtProof: MainThreadProof?// at some point in the codebase:asynchronouslyPerformOnMainThread { mtProof in globalMtProof = mtProof}// later on:functionWhichMustBeCalledOnTheMainThread(mtProof: globalMtProof!)2nd try: leveraging non-escaping functionsThe solution is reproduced below in full..It’s very compact, so take a glance..We’ll break it down shortly.// Core.swift// Core.swiftimport Foundationpublic protocol StaticThread { static var dispatchQueue: DispatchQueue { get }}public struct ThreadProofHelperInput { private init() { }}public struct ThreadProofHelperOutput<ST: StaticThread> { fileprivate init() { }}public typealias ThreadProof<ST: StaticThread> = (ThreadProofHelperInput) -> ThreadProofHelperOutput<ST>public extension StaticThread {public static func unsafelyConjureThreadProof<Output>( andUseItIn work: (ThreadProof<Self>) -> Output ) -> Output { let threadProof: ThreadProof<Self> = { _ in .init() } return work(threadProof) }public static func sync<Output>( work: (ThreadProof<Self>) -> Output ) -> Output {return dispatchQueue.sync { return unsafelyConjureThreadProof(andUseItIn: work) } }public static func async( work: @escaping (ThreadProof<Self>) -> Void ) {dispatchQueue.async { unsafelyConjureThreadProof(andUseItIn: work) } }}// MainThread.swiftimport Foundationpublic enum MainThread: StaticThread { public static let dispatchQueue = DispatchQueue.main}public typealias MainThreadProof = ThreadProof<MainThread>Let’s break down the tricky bits in this piece of code.First, note that the solution is no longer specific to the main thread, but is generic on types which conform to the StaticThread protocol (i.e. types which encode threads known to exist at compile time, by exposing a statically-accessible dispatch queue).The main-thread specialization of the solution is based on theenum MainThread: StaticThread type (it is a case-less enum because we don’t want instances of the type to exist).We’ll begin by looking at ThreadProof<ST> — which is now a typealias rather than a 1st-class type..When unpacked, ThreadProof<ST> evaluates to(ThreadProofHelperInput) -> ThreadProofHelperOutput<ST> .Crucially, ThreadProof<ST> type is itself a function..Swift functions are different from non-function types in that functions provided as arguments to other functions are either escaping or non-escaping (if a function argument is not declared as @escaping, then it is non-escaping).You can check out some examples for escaping/non-escaping closures in the official swift documentation, but the core concept is simple:A closure is said to escape a function when the closure is passed as an argument to the function, but is called after the function returns.In swift, a non-escaping argumentClosure which is passed as an argument to some executedClosure is guaranteed by the compiler to be inaccessible by the time executedClosure is done executing.That means you can’t store a non-escaping argumentClosure in a variable, and you can’t pass it as an argument to any asynchronously-executed call.In other words, a non-escaping argumentClosure can’t cross thread boundaries.Support for escaping/non-escaping closures is the primary non-standard language feature required for our thread-safety verification trick to be implementable in an arbitrary programming language.Now, if we look at Core.swift, we will see that a ThreadProof<ST> instance is only ever created inside StaticThread.unsafelyConjureThreadProof(andUseItIn:),which then immediately passes it to the work argument, and executes work.The type of the work argument is(ThreadProof<Self>) -> Output.In particular, the type of the work argument is not(@escaping ThreadProof<Self>) -> Output.Which means that the only place in the codebase which creates an instance of ThreadProof<T> only performs a single task with this instance: it passes it as a non-escaping argument to a work function, and immediately (synchronously) executes said work function.This guarantees that instances of ThreadProof<ST> will only ever be available to code which synchronously executes from the point in which StaticThread.unsafelyConjureThreadProof(andUseItIn:) is called.To contextualize: if we make sure to only ever call the MainThread.unsafelyConjureThreadProof(andUseItIn:) function from the main thread, then instances of MainThreadProof will only ever be available on the main thread* (see Misc/Threads and DispatchQueues section at the end for subtleties).That is precisely what we do with the (safe) MainThread.sync and MainThread.async calls.Note that instances of ThreadProof<ST> cannot be created outside of the Core.swift file..Why?Remember, the type of ThreadProof<ST> evaluates to(ThreadProofHelperInput) -> ThreadProofHelperOutput<ST>,i.e..to a function which returns an instance of ThreadProofHelperOutput<ST> given an instance of ThreadProofHelperInput.If you can’t get ahold of an instance of ThreadProofHelperOutput<ST>, then you can’t create a function which returns an instance of ThreadProofHelperOutput<ST> (what would it return?).There are only 2 ways to obtain an instance of ThreadProofHelperOutput<T> which are potentially exposed to world outside of Core.swift:Execute a ThreadProof<ST> closure by passing it an instance of ThreadProofHelperInput as input.2..Directly initialize an instance of ThreadProofHelperOutput<ST>.Both lead to dead-ends:Instances of ThreadProofHelperInput can’t be created, since the type only has a single initializer, marked as private..Therefore ThreadProof<ST> functions can never be executed — and thus can never return an instance of ThreadProofHelperOutput<ST>.Creating instances of ThreadProofHelperOutput<ST> directly is only possible inside of Core.swift (it has a single initializer, marked as fileprivate).Tying it all togetherOur example looks much like it did earlier.. More details

Leave a Reply