Pull to refresh
483.73
Яндекс
Как мы делаем Яндекс

Обновления в Swift Concurrency: что нас ждёт в Swift 6

Reading time20 min
Views4.7K

Привет, Хабр! Меня зовут Никита, я занимаюсь iOS-разработкой в Яндекс Диске и ещё веду телеграм-канал. Как вы знаете, прошлой осенью зарелизился Swift 6, а вместе c ним появились и строгие проверки для защиты от датарейсов, связанные со Swift Concurrency. В Swift 5 такие проверки включались при помощи флага -strict-concurrency=complete, но, по заявлениям разработчиков Swift, были слишком консервативными:

Complete concurrency checking in Swift 5.10 was overly restrictive, and Swift 6 removes many false-positive data-race warnings through better Sendable inference, new analysis that proves mutually exclusive access when passing values with non-Sendable type over isolation boundaries, and more.

Я, как и многие из вас, пробовал их включать и получал несколько тысяч ошибок компиляции. Поэтому мне стало интересно, насколько в Swift 6 всё стало лучше. Проще ли теперь перейти на новую concurrency-модель и чем она отличается от версии из Swift 5? Ну и ответить на главный вопрос: как «правильно» мигрировать кодовую базу на Swift 6?

В этой статье я постараюсь разобраться с основными изменениями в каждом пропозале и поделюсь своими заметками, тем, что мне показалось самым важным или интересным. В конце статьи бонус — Playground с тестами для каждого пропозала, чтобы можно было поиграть с кодом, детальнее разобраться с изменениями и понять, как они влияют на код, написанный на Swift 5.

P. S. Сами пропозалы я расположил не в хронологическом порядке, а в том, который мне показался более удобным для чтения.

Обзор пропозалов

Region based Isolation

Proposal: SE-0414

В Swift 5 все переменные, которые вы передаёте между акторами, должны соответствовать Sendable, поэтому следующий код не будет компилироваться, несмотря на то что никаких датарейсов в нём быть не может:

// Not Sendable.
class Client {
  init(name: String) { ... }
}

@MainActor
func storeClient(_ c: Client) { ... }

func openNewAccount(name: String) async {
  let client = Client(name: name)
  // ERROR: `Client` is non-`Sendable`!
  await storeClient(client)
}

Теперь Swift для каждой новой переменной будет строить регионы изоляции и с их помощью определять, когда безопасно передавать non-Sendable-переменные между акторами, а когда нет.

И выдавать ошибки, только если в коде действительно возможен датарейс:

func openNewAccount(name: String) async {
  let client = Client(name: name)
  await storeClient(client)
  // ERROR: Already transferred into clientStore’s isolation domain… this could race!
  client.logToAuditStream()
}

И самое интересное: если async-функция не изолируется ни на каком акторе, а значит, всегда наследует текущего актора, то Swift разрешит использовать переменную дальше, потому что это безопасно:

func nonIsolatedCallee(_ x: NonSendable) async { ... }
func useValue(_ x: NonSendable) { … }
@MainActor func transferToMainActor<T>(_ t: T) { ... }

actor MyActor {
  var state: NonSendable

  func example() async {
    let x = NonSendable()

    await nonIsolatedCallee(x)

    useValue(x) // OK!

    await transferToMainActor(x) // OK!

    // ERROR: After transferring to main actor, permanently in main actor, so we can’t use it.
    useValue(x)
  }
}

Ещё из интересного

В будущих версиях Swift обещают сделать transferring-параметры функций, чтобы можно было явно указать, что переменная перейдёт к другому актору:

func someSynchronousFunction(_ x: transferring NonSendable) {
  Task {
    doSomething(x)
  }
}

А ещё это наконец-то разрешит писать нормальные инициализаторы у акторов:

actor Actor {
  var field: NonSendable

  init(_ x: transferring NonSendable) {
    self.field = x
  }
}

И такой же аналог для возвращаемых значений функций:

func example(_ x: NonSendable) async -> @returnsIsolated NonSendable? {
  if x.boolean {
    return NonSendable()
  }
  return nil
}

sending parameter and result values

Proposal: SE-0430

В описании прошлого пропозала я упоминал, что в будущем нам обещают transferring-параметры для функций. Так вот, будущее наступило! В Swift 6 они уже есть, правда, называются sending.

sending-параметры расширяют возможности регионов изоляции, потому что позволяют явно указать, какие значения будут переданы в новый регион изоляции. А значит, не смогут больше использоваться в старом. Например, такой код в Swift 5 мог привести к датарейсу:

@MainActor var mainActorState: NonSendable?

nonisolated func test() async {
  let ns = await withCheckedContinuation { continuation in
    Task { @MainActor in
      let ns = NonSendable()
      // Oh no! `NonSendable` is passed from the main actor to a nonisolated context here!
      continuation.resume(returning: ns)

      // Save `ns` to main actor state for concurrent access later on
      mainActorState = ns
    }
  }

  // `ns` and `mainActorState` are now the same non-Sendable value; concurrent access is possible!
  ns.mutate()
}

А теперь выдаёт корректные ошибки:

@MainActor
func acceptSend(_: sending NonSendable) {}

func sendToMain() async {
  let ns = NonSendable()

  // ERROR: sending `ns` may cause a race.
  // NOTE: `ns` is passed as a `sending` parameter to `acceptSend`. Local uses could race with later uses in `acceptSend`.
  await acceptSend(ns)

  // NOTE: access here could race.
  print(ns)
}

Это здорово, потому что помечать все Sendable протоколом или оборачивать в @unchecked Sendable — едва ли выход из ситуации. Такой подход, скорее, просто скроет проблему, чем её решит.

Поэтому чтобы безопасно передавать non-Sendable-типы между акторами, в Swift 6 нужно использовать sending-параметры. Это позволит компилятору гарантировать, что такие переменные не могут использоваться одновременно несколькими акторами, а значит, вам больше не нужно переживать за возможные датарейсы.

Какие системные API уже поддерживают sending:

Strict concurrency for global variables

Proposal: SE-0412

В этом пропозале добавляются новые правила для работы с глобальными переменными.
Суть в том, что в Swift 6 все глобальные переменные должны быть изолированы на каком-то акторе либо являться Sendable-константами:

@MainActor
var nonSendable: NonSendable = .init() // OK!

let sendableConstant: Int = .zero // OK!

var sendableVariable: Int = .zero // ERROR: Is not concurrency-safe because it is nonisolated global shared mutable state.

С одной стороны, это потокобезопасно, ведь теперь компилятор может гарантировать правильную работу регионов изоляции, включая глобальные переменные. С другой стороны, любая переменная теперь — ошибка компиляции. Из-за этого переход на Swift 6 в хоть сколько-то крупном проекте станет серьёзным челленджем.

Поэтому этот пропозал собрал немало критики: например от Алекса Гребенюка, автора Nuke и Pulse.

Inheritance of actor isolation

Proposal: SE-0420

В этом пропозале добавляется немного динамики в наследование функциями акторов, на которых они должны выполняться.

Раньше все асинхронные функции были трёх видов:

  • неизолированные, выполняющиеся без привязки к какому-либо актору;

  • методы актора, изолированные на этом же акторе;

  • изолированные на глобальном акторе, например @MainActor.

Теперь актор для изоляции можно явно передать в функцию при помощи isolated (any Actor)? или чуть менее явно при помощи дефолтного параметра #isolation.

Динамическое наследование изоляции позволяет не делать прыжков между акторами, а значит, не пересекать их регионы изоляции, что разрешит использовать асинхронные функции для non-Sendable-типов:

/// This class type is not Sendable.
class Counter {
  var count = 0
}

extension Counter {
  /// Since this is an async function, if it were just declared
  /// non-isolated, calling it from an isolated context would be
  /// forbidden because it requires sharing a non-Sendable value
  /// between concurrency domains.  Inheriting isolation makes it
  /// okay. This is a contrived example chosen for its simplicity.
  func incrementAndSleep(isolation: isolated (any Actor)? = #isolation) async {
    count += 1
    await withCheckedContinuation { continuation in
      continuation.resume()
    }
  }
}

actor MyActor {
  var counter = Counter()
}

extension MyActor {
  func testActor(other: MyActor) async {
    // Allowed.
    await counter.incrementAndSleep()
    
    // Not allowed.
    await counter.incrementAndSleep(isolation: other)
    
    // Not allowed.
    await counter.incrementAndSleep(isolation: MainActor.shared)
    
    // Not allowed.
    await counter.incrementAndSleep(isolation: nil)
  }
}

@MainActor
func testMainActor(counter: Counter) async {
  // Allowed.
  await counter.incrementAndSleep()
  
  // Not allowed.
  await counter.incrementAndSleep(isolation: nil)
}

func testNonIsolated(counter: Counter) async {
  // Allowed.
  await counter.incrementAndSleep()
  
  // Not allowed.
  await counter.incrementAndSleep(isolation: MainActor.shared)
}

И самое главное, это частично решает проблему reentrancy у акторов, потому что теперь вызовы асинхронных функций, которые наследуют изоляцию текущего актора, будут гарантированно вызваны синхронно.

Поэтому следующий тест будет стабильно проходить:

extension MyActor {
  func incrementAndAssertCountEquals(_ expectedCount: Int) async {
    await counter.incrementAndSleep() // Potential reentrancy point.
    #expect(counter.count == expectedCount)
  }
}

func testActorReentrancy() async {
  let actor = MyActor()
  var tasks = [Task<Void, Never>]()
  
  for iteration in 1 … 100 {
    tasks.append(Task { @MainActor in
      await actor.incrementAndAssertCountEquals(iteration)
    })
  }
  
  // Wait for all enqueued tasks to finish.
  for task in tasks {
    _ = await task.value
  }
}

@isolated(any) Function Types

Proposal: SE-0431

Этот пропозал продолжает идею динамического наследования акторов функциями и добавляет в кложуры дополнительный сторадж для хранения информации об акторе, на котором такие функции должны выполняться.

Наличие стораджа решает сразу две проблемы в текущей Structured Concurrency.

Первая проблема — отсутствие прямого шедулинга тасок на нужного актора. У Swift просто нет информации о том, где таска должна выполняться. И несмотря на то что таска может быть помечена, например, как @MainActor, она всё равно всегда будет стартовать на глобальном экзекьюторе и только после этого переключаться на @MainActor. Такое поведение не только крайне неэффективно, но и ломает транзитивный порядок эвентов — там, где он должен быть гарантирован.

Например, такой код до Swift 6 выводил буквы в случайном порядке, но теперь он строго гарантирован и соответствует порядку создания тасок:

@MainActor
func testMainActor() {
  Task { print(“a”) }
  Task { print(“b”) }
  Task { print(“c”) }
}

Вторая проблема — невозможность замкнуть текущего актора для создаваемых кложур. Единственный выход из такой ситуации сейчас — изоляция на глобальных акторах.

На самом деле этот пропозал только готовит почву для полноценного внедрения такой функциональности, которая появится уже скоро в Closure isolation control. Это позволит замыкать актора через capture list так же, как сейчас переменные:

func delay(operation: @isolated(any) () -> ()) {
  let isolation = operation.isolation
  Task { [isolated isolation] in // Tentative syntax from the isolated captures pitch.
    print(“waking”)
    operation() // Does not cross an isolation barrier and so is synchronous.
    print(“finished”)
  }
}

Task Executor Preference

Proposal: SE-0417

В этом пропозале у тасок появляется возможность выполняться на кастомном экзекьюторе. Но чтобы понять, зачем это нужно, стоит сначала разобраться, кто такие экзекьюторы, чем они отличаются от акторов и какую роль каждый из них играет в Structured Concurrency.

Actor в мире Structured Concurrency — это единственная мера изоляции. Любая асинхронная функция может быть изолирована на каком-то акторе (глобальном или локальном) и беспрепятственно использовать его стейт. Потому что выполнение таких функций последовательно: в любой момент времени может выполняться только одна из таких функций. Иначе функция будет являться неизолированной и выполняться параллельно с любыми другими частями программы.

Экзекьюторы в Structured Concurrency ответственны лишь за то, чтобы выполнять джобы, которые на них шедулятся. И в Structured Concurrency стандартный экзекьютор всего один — GlobalConcurrentExecutor. Он как раз держит фиксированный пул тредов, распределяя джобы между ними. Над ним есть только одна обёртка в виде стандартной реализации SerialExecutor для акторов, которая просто шедулит джобы на GlobalConcurrentExecutor, но назначает их по одной, чтобы они не выполнялись параллельно.

И какая проблема возникает у такого подхода? Конечно, долгие таски! Ведь запустив несколько таких тасок, можно парализовать работу всей Structured Concurrency в приложении. Как мы помним, стандартный экзекьютор у нас один, все задачи выполняются только им, а пул его тредов ограничен. Например, у iPhone 6s есть всего два треда в этом пуле, а значит, достаточно запустить две долгие таски, и, кроме них, ничего выполняться больше не будет.

Конечно, такое поведение всё ещё очень зависит от приоритетов тасок, и иногда потоков может быть больше двух.

Эта проблема решилась уже в Swift 5.9 с появлением Custom Actor Executors и возможностью написать свой кастомный экзекьютор для актора, чтобы добавить новые треды в пул Structured Concurrency и выполнять какие-то долгие таски на них.

final class SpecificThreadExecutor: SerialExecutor {
  // Simplified handle to some specific thread.
  let someThread: SomeThread
  
  func enqueue(_ job: consuming ExecutorJob) {
    // In order to escape it to the run{} closure.
    let unownedJob = UnownedExecutorJob(job)
    someThread.run {
      unownedJob.runSynchronously(on: self)
    }
  }

  func asUnownedSerialExecutor() -> UnownedSerialExecutor {
    UnownedSerialExecutor(ordinary: self)
  }
}

actor Worker {
  let executor = SpecificThreadExecutor()

  nonisolated var unownedExecutor: UnownedSerialExecutor { 
    // Use the shared specific thread executor mentioned above.
    executor.asUnownedSerialExecutor()
  }
}

Но что насчёт тасок, которые не изолированы ни на каком акторе? Ведь они всё ещё будут выполняться только дефолтным GlobalConcurrentExecutor, а значит, проблема долгих тасок не решена до конца.

Поэтому в Swift 6 и появились Task Executor Preference. Такие преференсы позволяют указать для таски и всех её чайлд-тасок предпочтительный экзекьютор, который будет использован вместо дефолтного GlobalConcurrentExecutor:

Task(executorPreference: executor) {
  // Starts and runs on the `executor`.
  await nonisolatedAsyncFunc()
}

Task.detached(executorPreference: executor) {
  // Starts and runs on the `executor`.
  await nonisolatedAsyncFunc()
}

await withDiscardingTaskGroup { group in 
  group.addTask(executorPreference: executor) { 
    // Starts and runs on the `executor`.
    await nonisolatedAsyncFunc()
  }
}

func nonisolatedAsyncFunc() async -> Int {
  // If the Task has a specific executor preference,
  // runs on that `executor` rather than on the default global concurrent executor.
  return 42
}

Преференс здесь означает нестрогую привязку к этому экзекьютору. Поэтому если у чайлд-таски будет изоляция какого-то актора, у которого, в свою очередь, тоже может быть кастомный экзекьютор, как, например, у @MainActor, то выполняться такая таска будет на кастомном экзекьюторе актора, игнорируя executorPreference родительской таски.

Правила выбора экзекьютора в Swift 5:
[ func / closure ] - /* where should it execute? */
                               |
                         +--------------+          +==========================+
                 +- no - | is isolated? | - yes -> | default (actor) executor |
                 |       +--------------+          +==========================+
                 |
                 |                                 +==========================+
                 +-------------------------------> | on global conc. executor |
                                                   +==========================+

Новые правила для Swift 6:
[ func / closure ] - /* where should it execute? */
                             |
                   +--------------+          +===========================+
         +-------- | is isolated? | - yes -> | actor has unownedExecutor |
         |         +--------------+          +===========================+
         |                                       |                |      
         |                                      yes               no
         |                                       |                |
         |                                       v                v
         |                  +=======================+    /* task executor preference? */
         |                  | on specified executor |        |                   |
         |                  +=======================+       yes                  no
         |                                                   |                   |
         |                                                   |                   v
         |                                                   |    +==========================+
         |                                                   |    | default (actor) executor |
         |                                                   v    +==========================+
         v                                   +==============================+
/* task executor preference? */ ---- yes ----> | on Task's preferred executor |
         |                                   +==============================+
         no
         |
         v
+===============================+
| on global concurrent executor |
+===============================+

Dynamic actor isolation enforcement from non-strict-concurrency contexts

Proposal: SE-0423

Это небольшой пропозал, в котором добавляется возможность использовать @preconcurrency-атрибут не только для импортов библиотек, но и для конформансов протоколам из этих библиотек.

Такая функциональность очень полезна для постепенного перехода на Swift 6 со strict concurrency checking. Особенно если вы импортите библиотеки, которые ещё не поддерживают strict concurrency checking или в принципе не могут её поддерживать, потому что написаны на C, C++ или Objective-C.

Например, такая библиотека имеет следующий протокол и гарантирует его использование только с MainThread:

public protocol ViewDelegateProtocol {
  func respondToUIEvent()
}

Тогда его конформанс-классом, изолированным на @MainActor, будет выдавать ошибку компиляции, хотя никаких проблем с изоляцией здесь нет:

import NotMyLibrary

@MainActor
class MyViewController: ViewDelegateProtocol {
  // ERROR: @MainActor function cannot satisfy a nonisolated requirement.
  func respondToUIEvent() { ... }
}

Да, это можно исправить, добавив рантайм проверки:

import NotMyLibrary

@MainActor
class MyViewController: ViewDelegateProtocol {
  nonisolated func respondToUIEvent() {
    MainActor.assumeIsolated { ... }
  }
}

Но это едва ли хорошее решение, потому что теперь вы потеряете все компайл-тайм-проверки при вызове этой функции, причём даже внутри своего модуля. А значит, любое обращение к этой функции из асинхронного контекста больше не будет переводить её на MainActor и, следовательно, перестанет вызывать креши в рантайме.

Поэтому, чтобы не потерять потокобезопасность и при этом всё-таки конформить такому протоколу, в Swift 6 можно пометить конформанс как @preconcurrency:

import NotMyLibrary

@MainActor
class MyViewController: @preconcurrency ViewDelegateProtocol {
  func respondToUIEvent() { ... }
}

Тогда все компайл-тайм-проверки при вызове этой функции из текущего модуля останутся на месте, это позволит и дальше продолжать писать безопасный асинхронный код, не задумываясь о том, из какого контекста он вызывается. А вне текущего модуля проверки будут перенесены в рантайм, благодаря чему останется возможность для дебага этого кода.

Remove Actor Isolation Inference caused by Property Wrappers

Proposal: SE-0401

В этом пропозале убирается неявное наследование изоляции для классов или структур из @propertyWrapper, в которых wrappedValue изолировано на каком-то глобальном акторе.

Для понимания проблемы рассмотрим пример из Swift 5. В нём DBConnection будет наследовать изоляцию от приватного свойства connectionID, которое изолировано на DatabaseActor.

// A property wrapper for use with our database library.
@propertyWrapper
struct DBParameter<T> {
  @DatabaseActor public var wrappedValue: T
}

// Inferred `@DatabaseActor` isolation because of use of `@DBParameter`.
struct DBConnection {
  @DBParameter private var connectionID: Int

  func executeQuery(_ query: String) -> [DBRow] { ... }
}

И если где-то в другом файле будет использоваться DBConnection, то он будет изолирован на DatabaseActor неявно.

@DatabaseActor
func fetchOrdersFromDatabase() async -> [Order] {
  let connection = DBConnection()

  // No `await` needed here, because `connection` is also isolated to `DatabaseActor`.
  connection.executeQuery("...")
}

И самое интересное: если изменить @propertyWrapper у connectionID, то код перестанет компилироваться. Это первый прецедент в Swift, когда изменение в приватном свойстве приводит к ошибкам компиляции в отдельном файле, вообще никак не связанном с этим свойством. Поэтому в Swift 6 такое наследование и было удалено.

Isolated default value expressions

Proposal: SE-0411

В этом пропозале частично сняли ограничения для дефолтных параметров функций, имеющих изоляцию на каком-то акторе.

Например, класс, изолированный на @MainActor, нельзя передать как дефолтный параметр функции, которая также изолирована на @MainActor:

@MainActor class C {}

@MainActor func f(c: C = C()) {} // ERROR: Call to main actor-isolated initializer `init()` in a synchronous nonisolated context.

@MainActor func useFromMainActor() {
  f()
}

Даже несмотря на то, что этот код полностью безопасен, в Swift 5 он не компилировался, а в Swift 6 эти чрезмерные ограничения были сняты.

Также в пропозале добавили больше ограничений для хранимых свойств. До этого компилятор игнорировал изоляцию переменных и при инициализации создавал начальные значения свойств синхронно:

@MainActor func requiresMainActor() -> Int { ... }
@AnotherActor func requiresAnotherActor() -> Int { ... }

class C {
  @MainActor var x1 = requiresMainActor()
  @AnotherActor var x2 = requiresAnotherActor()

  nonisolated init() {} // No Error!
}

В Swift 6 предыдущий пример будет выдавать ошибку: переменные не инициализированы. И чтобы её исправить, нужно написать асинхронный .init():

class C {
  @MainActor var x1 = requiresMainActor()
  @AnotherActor var x2 = requiresAnotherActor()

  nonisolated init() async {
    self.x1 = await requiresMainActor()
    self.x2 = await requiresAnotherActor()
  }
}

Интересный кейс здесь ещё в том, что про memberwise инициализаторы структур тоже не забыли. Теперь они будут синтезированы уже с нужным актором, и их не придётся писать вручную:

class NonSendable {}

@MainActor struct MyModel {
  // @MainActor inferred from annotation on enclosing struct.
  var value: NonSendable = .init()

  /* Compiler-synthesized memberwise init is @MainActor.
  @MainActor
  init(value: NonSendable = .init()) {
    self.value = value
  }
  */
}

Inferring Sendable for methods and key path literals

Proposal: SE-0418

В этом пропозале добавляется @Sendable-конформанс функциям для Sendable-типов. Очень логичное изменение, и непонятно, почему раньше это было не так:

struct SendableType: Sendable {
 func someMethod() {}
}

func test() {
 let value = SendableType()
 
 // WARNING: Converting non-sendable function value to `@Sendable () async -> Void` may introduce data races.
 Task<Void, Never>(operation: value.someMethod)
}

В Swift 6 код из примера выше больше не выдаёт варнингов.

Также изменяется поведение, добавленное в [SE-0302] Sendable and @Sendable closures. Теперь все KeyPath в Swift вместо того, чтобы быть по умолчанию Sendable, будут наследовать это от своих аргументов. Что опять же очень логично и уберёт ещё пачку надоедливых варнингов:

class Info : Hashable {
  // Some information about the user.
}

public struct Entry {}

public struct User {
  public subscript(info: Info) -> Entry {
    // Find entry based on the given info.
  }
}

// WARNING: Cannot form key path that captures non-sendable type 'Info'.
let entry: KeyPath<User, Entry> = \.[Info()]

Generalize effect polymorphism for AsyncSequence and AsyncIteratorProtocol

Proposal: SE-0421

В этом пропозале добавили полиморфизм для возвращаемых ошибок и типов изоляции, связанных с AsyncSequence и AsyncIteratorProtocol. Что всё это значит? Давайте разберёмся по порядку, начнём с ошибок.

До Swift 6 определение AsyncIteratorProtocol выглядело так:

@rethrows
protocol AsyncIteratorProtocol {
  associatedtype Element

  mutating func next() async throws -> Element?
}

Это означало, что мы не можем типизировать ошибки, возвращаемые этим итератором, и всегда будем получать any Error — даже в случае, если итератор никаких ошибок не выбрасывает в принципе. Чтобы исправить последнее и не писать везде try, в Swift 5 использовался экспериментальный флаг @rethrows, который скорее скрывал проблему, чем её исправлял. А ещё он не давал AsyncSequence использовать primary associated types, из-за чего нельзя было писать some/any AsyncSequence<Element>.

На помощь пришли Typed throws и решили все проблемы с возвращаемыми ошибками. В Swift 6 AsyncSequence и AsyncIteratorProtocol выглядят законченными:

protocol AsyncIteratorProtocol<Element, Failure> {
  associatedtype Element

  @available(SwiftStdlib 6.0, *)
  associatedtype Failure: Error = any Error

  @available(SwiftStdlib 6.0, *)
  mutating func next(isolation actor: isolated (any Actor)?) async throws(Failure) -> Element?
}

Теперь про проблему с акторами. В коде выше видно, что функция next(isolation:) поддерживает динамическую изоляцию. Это нужно, чтобы исправить надоедливый варнинг с возвращением non-Sendable-типов из этой функции:

class NotSendable { ... }

@MainActor
func iterate(over stream: AsyncStream<NotSendable>) {
  for await element in stream { // Warning: non-sendable type 'NotSendable?' returned by implicitly asynchronous call to nonisolated function cannot cross actor boundary.
  }
}

В Swift 6 этот код больше не создаёт варнингов.

Правда, у всех этих улучшений есть один нюанс: @available(SwiftStdlib 6.0, *). Другими словами, нам нужен рантайм Swift 6, а значит, фиксы будут работать только с iOS 18, пу-пу-пу...

Custom isolation checking for SerialExecutor

Proposal: SE-0424

В предыдущем пропозале [SE-0392] Custom Actor Executors в Structured Concurrency добавили кастомные экзекьюторы для акторов, что открыло дополнительные возможности для адаптации Structured Concurrency под крупные проекты.

Но большую кодовую базу невозможно мигрировать на новую Concurrency-модель сразу целиком, поэтому очень важно иметь возможность использовать обе модели одновременно.

[SE-0424] Custom isolation checking for SerialExecutor как раз расширяет возможности такого использования. Он позволяет Structured Concurrency всегда знать о том, на каком экзекьюторе происходит выполнение кода — неважно, находимся ли мы сейчас внутри какой-то Task или нет.

Рантайм Structured Concurrency следит за текущим экзекьютором Task, храня его в thread-local-сторадже. Так как все возможные async-вызовы всегда обновляют текущего актора в этом сторадже, то assertIsolated и assumeIsolated работают корректно, если мы находимся внутри Task.

Но всё меняется, если мы используем своего кастомного экзекьютора, например, DispatchSerialQueue. Ведь на него мы можем шедулить джобы не только из контекста Task, но и через старый добрый DispatchQueue.async, из-за чего рантайм Structured Concurrency ничего не будет знать о текущем экзекьюторе и все проверки будут крешить.
Для примера можно рассмотреть код:

actor Caplin {
  let queue: DispatchSerialQueue = .init(label: "CoolQueue")

  var num: Int // Actor isolated state.

  // Use the queue as this actor's `SerialExecutor`.
  nonisolated var unownedExecutor: UnownedSerialExecutor {
    queue.asUnownedSerialExecutor()
  }
  
  nonisolated func connect() {
    queue.async {
      // Guaranteed to execute on queue which is the same as self's serial executor.
      self.queue.assertIsolated() // CRASH: Incorrect actor executor assumption.
      self.assumeIsolated { caplin in // CRASH: Incorrect actor executor assumption.
        caplin.num += 1
      }
    }
  }
}

Чтобы решить эту проблему в Swift 6, был добавлен дополнительный метод к SerialExecutor. Теперь кастомные экзекьюторы могут проверить, выполняют ли они текущий код или нет, и сообщить об этом Structured Concurrency с помощью креша:

extension DispatchSerialQueue { 
  public func checkIsolated() {
    dispatchPrecondition(condition: .onQueue(self)) // Existing Dispatch API.
  }
}

Кстати, интересный факт: с iOS 17 Apple открыли инициализатор у DispatchSerialQueue, и теперь последовательные очереди можно создавать не только через DispatchQueue.init(label:). Более того, теперь все последовательные очереди соответствуют протоколу SerialExecutor, а значит, их можно использовать как экзекьюторы акторов без каких-либо обёрток.

Usability of global-actor-isolated types

Proposal: SE-0434

Этот пропозал приносит в Structured Concurrency три улучшения, связанных с использованием глобальных акторов.

Первое улучшение касается Value Types, у которых изоляция на глобальном акторе. Для примера возьмём структуру, у которой все свойства — Sendable-типы. В таком случае let-переменные будут неявно наследовать nonisolated, а переменные, объявленные через var, — нет.

Такое поведение накладывает много ограничений, особенно при работе с протоколами:

@MainActor struct S {
  nonisolated(unsafe) var x: Int = 0
}

extension S: Equatable {
  static nonisolated func ==(lhs: S, rhs: S) -> Bool {
    return lhs.x == rhs.x
  }
}

Единственный выход из ситуации — использовать nonisolated(unsafe) (хотя на самом деле ничего небезопасного здесь нет). Так как свойство соответствует Sendable, при его использовании не должны возникать никакие датарейсы в принципе. Поэтому все датарейсы, которые потенциально могут возникнуть, будут связаны не с самим свойством, а со структурой, которая его содержит. Например, если мы решим эту структуру изменить с двух разных потоков одновременно. Но так как эта структура изолирована на глобальном акторе, то её параллельные изменения невозможны.

Поэтому до тех пор, пока Swift гарантирует безопасный доступ к самой структуре, доступ ко всем её Sendable-полям, неважно — let или var, также становится безопасным. В Swift 6 nonisolated(unsafe) из примера выше больше не нужен.

Второе улучшение касается замыканий, изолированных на глобальных акторах. Теперь они неявно наследуют @Sendable-атрибут, что позволяет коду из следующего примера больше не выдавать ошибок:

func test(globallyIsolated: @escaping @MainActor () -> Void) {
  Task {
    // ERROR: capture of `globallyIsolated` with non-sendable type `@MainActor () -> Void` in a `@Sendable` closure.
    await globallyIsolated()
  }
}

А ещё такие замыкания могут при этом захватывать non-Sendable переменные:

class NonSendable {}

func test() {
  let ns = NonSendable()

  let closure = { @MainActor in
    print(ns)
  }

  Task {
    await closure() // OK.
  }
}

Третье улучшение касается наследников non-Sendable-классов. Раньше наследники таких классов могли быть только non-isolated:

class NonSendable {}

@MainActor
class Subclass: NonSendable {} // ERROR: main actor-isolated class `Subclass` has different actor isolation from nonisolated superclass `NonSendable`.

Теперь их изоляция на глобальном акторе — это ок. Такие наследники больше не наследуют неявный конформанс Sendable и могут и дальше жить свою спокойную потоко-небезопасную жизнь.

Итак, это был последний пропозал. И чтобы подвести какой-то итог и посмотреть целиком на то, что получилось, давайте разберём вижн улучшений или работу над ошибками для Structured Concurrency.

Improving the approachability of data-race safety

Vision: approachable-concurrency

Краткое содержание вижна о том, какой набор возможностей и компонентов должна иметь модель Swift Concurrency в будущем.

Возможность для библиотек по умолчанию быть «однопоточными»

Это означает, что если в модуле будет включён такой флаг, то все сущности будут неявно изолированы на @MainActor. В вижне говорится, что такой флаг будет очень полезен для Command-line tools или скриптов.

Изолированные протоколы

Здесь есть несколько идей, что можно улучшить.
Во-первых, дать протоколам, как и функциям, возможность быть динамически изолированными, примерно как у замыканий сейчас:

() -> Bool // Not Sendable and can have any kind of isolation.
@MainActor () -> Bool // Isolated to a specific global actor.
@isolated(any) () -> Bool // Might be isolated to a specific actor that it carries around with it dynamically.

Во-вторых, дать возможность использовать isolated-конформансы для nonisolated-протоколов:

@MainActor
final class MainActorClass: Equatable {
  // OK. Treated as Equatable only under @MainActor.
  static func == (lhs: MainActorClass, rhs: MainActorClass) -> Bool {
    /* ... */
  }
}

Но все эти идеи пока очень туманны и пестрят непродуманными деталями. Например, непонятно, как работают такие протоколы с дженериками. Тем не менее замыслы достаточно амбициозны и полезны.

Изолированные сабклассы и оверрайды

Здесь предлагается расширить [SE-0434] Usability of global-actor-isolated types и разрешить изолированные оверрайды для Sendable-классов. Для этого нужно будет трекать все ссылки на такие сабклассы и запрещать их конвертацию к суперклассу. Казалось бы, при чём тут SOLID?_

Реверт изменений для nonisolated-функций

Подразумевает отмену изменений сделанных в [SE-0338] Clarify the Execution of Non-Actor-Isolated Async Functions. А именно: вернуть неявное наследование актора неизолированными асинхронными функциями для консистентности с синхронными. А чтобы функция не наследовала актора, нужно будет явно указать это при помощи атрибута @concurrent.

Подробнее о том, почему даже для разработчиков Swift Concurrency это стало фатальной ошибкой, можно почитать в Inherit isolation by default for async functions.

Добавление бриджей асинхронного и синхронного кода

Примерно как DispatchQueue.asyncAndWait, чтобы было проще переходить на Structured Concurrency.

Уменьшение количества ложно-положительных срабатываний рантайм-ассертов проверяющих изоляцию

Здесь предлагается улучшить работу рантайм-ассертов, которые были добавлены в [SE-0423]: Dynamic actor isolation enforcement from non-strict-concurrency contexts.

Выводы

На этом точно всё. Теперь, как и обещал, делюсь ссылкой на ConcurrencyPlayground. В этом проекте по каждому пропозалу вы можете найти тесты, которые проверяют его функциональность, и сравнить поведение кода для Swift 5 и Swift 6. Некоторые изменения также будут заметны при смене таргета с iOS 18 на iOS 17.

Надеюсь, статья была для вас полезной и вы узнали для себя много нового. И теперь переход на Swift 6 станет для вас чуть проще. Я также стараюсь писать заметки про iOS в своем телеграм-канале — подписывайтесь.

Tags:
Hubs:
Total votes 25: ↑25 and ↓0+28
Comments3

Articles

Information

Website
www.ya.ru
Registered
Founded
Employees
over 10,000 employees
Location
Россия