0323 — Асинхронная основная семантика

Настройка программы обычно происходит в основной функции, где разработчики ожидают выполнения операций до запуска других частей программы. В Objective-C, C++ и C есть инициализаторы, которые запускаются до запуска основной точки входа и могут взаимодействовать с системами параллелизма Swift способами, о которых трудно рассуждать. В модели параллелизма Swift написанная разработчиком асинхронная основная функция оборачивается задачей и ставится в очередь в основной очереди при запуске основной точки входа. Если инициализатор вставляет задачу в основную очередь, эта задача может быть выполнена перед основной функцией, поэтому настройка выполняется после запуска задач инициализатора.

Мотивация

Инициализаторы в Objective-C, C++ и C могут запускать код перед основной точкой входа при инициализации глобальных переменных. Если инициализатор создает задачу в основной очереди, эта задача инициализатора будет поставлена в очередь перед задачей, содержащей написанную пользователем асинхронную основную функцию. Это приводит к тому, что задача инициализатора может выполняться до выполнения основной функции. Для сравнения: синхронная основная функция запускается сразу после запуска инициализаторов, но до задач, созданных инициализаторами.

В приведенном ниже примере, посвященном совместимости Swift/C++, демонстрируется библиотека C++, которая несовместима с текущей семантикой асинхронной основной функции, поскольку она ожидает, что член deviceHandle AudioManager будет инициализирован до запуска задачи. Вместо этого программа утверждает, что основная функция выполняется после задачи, поэтому deviceHandle не инициализируется к моменту запуска задачи.

struct MyAudioManager {
  int deviceHandle = 0;

  MyAudioManager() {
    // 2. The constructor for the global variable inserts a task on the main
    //    queue.
    dispatch_async(dispatch_get_main_queue(), ^{
      // 4. The deviceHandle variable is still 0 because the initialization
      //    hasn't run yet, so this assert fires
      assert(deviceHandle != 0 && "Device handle not initialized!");
    });
  }
};

// 1. The global variable is dynamically initialized before the main entrypoint
MyAudioManager AudioManager;
@main struct Main {
  // 3. main entrypoint implicitly wraps this function in a task and enqueues it
  static func main() async {
    // This line should be used to initialize the deviceHandle before the tasks
    // are run, but it's enqueued after the crashing task, so we never get here.
    AudioManager.deviceHandle = getAudioDevice();
  }
}

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

Предложенное решение

Предлагаю следующие изменения:

  • Синхронно запустить основную функцию до первой точки приостановки.
  • Сделайте основную функцию неявно защищенной MainActor.

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

@main struct Main {
  static func main() async {
    // Executed synchronously before tasks created by the initializers run
    AudioManager.device = getAudioDevice()

    // At this point, the continuation is enqueued on the main queue.
    // Other code on the main queue can be run at this point.
    await doSomethingCool()
  }
}

Основная точка входа начинается в основном потоке. Чтобы гарантировать отсутствие точек приостановки, связанных с переключением потоков, функция main должна выполняться на MainActor. Дополнительным преимуществом этого является синхронность доступа к другим операциям MainActor. Поскольку функция main должна выполняться в основном потоке, она не может выполняться в других глобальных акторах, поэтому нам нужно запретить это.

@MainActor
var variable : Int = 32

@main struct Main {
  static func main() async {
    // not a suspension point because main is implicitly on the MainActor
    print(variable)
  }
}

Детальная разработка

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

@main struct Main {
  static func main() async {
    print("Hello1")
    await foo()
    await bar()
  }
}

Приведенная выше асинхронная основная функция разбита на три синхронные функции продолжения. _main1 — это точка входа в основную функцию, тогда как _main2 ставится в очередь _main1, а _main3 ставится в очередь _main2.

@main struct Main {
  static func _main3() {
    bar()
  }
  static func _main2() {
    foo()
    enqueue(_main3)
  }
  static func _main1() {
    print("Hello1")
    enqueue(_main2)
  }
}

Фрагмент ниже описывает, как основная точка входа запускает программу, помещая в очередь первое продолжение, _main1, перед запуском цикла выполнения для запуска задач, поставленных в очередь в основной очереди.

// The main entrypoint to the program with old async main semantics
func @main(_ argc: Int32, _ argv: UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) {
  enqueue(_main1)
  drainQueues()
}

Вместо того, чтобы ставить в очередь первое продолжение, мы можем выполнить его напрямую и позволить ему поставить в очередь следующее продолжение.

// The main entrypoint to the program with the new async main semantics
func @main(_ argc: Int32, _ argv: UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) {
  _main1()
  drainQueues()
}

Совместимость источника

Нет никаких изменений в исходном представлении асинхронной основной функции. Он по-прежнему будет написан с тем же синтаксисом, что и в структурированном параллелизме.

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

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

Влияние на стабильность ABI

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

Влияние на устойчивость API

Это не должно влиять на устойчивость API.

Рассматриваемые альтернативы

Отдельная функция синхронной настройки

@main struct Main {
  // Effectively like the synchronous main, run by the main entrypoint of the
  // program.
  static func setup() {
  }

  // Behaves the same way as it does currently
  static func main() async {
  }
}

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

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

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

Глобальный цикл выполнения

Python 3.4 представил библиотеку параллелизма asyncio, которая управлялась с помощью объекта цикла событий. Потребуются две основные функции, одна синхронная, а другая асинхронная. В синхронной функции вы должны инициализировать любое необходимое состояние, захватить цикл событий с помощью функции asyncio.get_event_loop() и указать ему запустить асинхронную основную функцию.

С тех пор Python перешел на asycio.run(), чтобы уменьшить количество шаблонов захвата цикла событий и обеспечения его надлежащего закрытия, но проблема использования нескольких основных функций все еще существует.

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

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