Асинхронный Rust в трех частях

Во введении мы сказали, что async/await это про futures и задачи. В первой части мы рассмотрели futures и теперь пришло время задач. Благо, мы с ними уже встречались, хоть мы их так и не называли. Последняя версия нашего основного цикла в первой части выглядела вот так:

let mut joined_future = Box::pin(future::join_all(futures));
let waker = futures::task::noop_waker();
let mut context = Context::from_waker(&waker);
while joined_future.as_mut().poll(&mut context).is_pending() {
    …
}

Playground #1

Показанная выше joined_future это самый простой пример задачи. Это верхнеуровневый future, опрашиваемый основным циклом. Здесь у нас всего одна задача, но ничего не мешает нам добавить больше. Если бы у нас была коллекция задач, мы могли бы добавить ее в рантайме.

Это и делает tokio::task::spawn. Мы можем переписать наш первоначальный пример Tokio с использованием spawn вместо join_all:

#[tokio::main]
async fn main() {
    let mut task_handles = Vec::new();
    for n in 1..=10 {
        task_handles.push(tokio::task::spawn(foo(n)));
    }
    for handle in task_handles {
        handle.await.unwrap();
    }
}

Playground #2

foo это все еще async fn, но в остальном это очень похоже на наш изначальный пример thread::spawn. Как и потоки, но не как обычные future, задачи начинают выполняться в фоновом режиме сразу после вызова spawn, так что применение .await к handle задачи работает как join к handle потока. В сетевых сервисах часто применяется подход с основным циклом, который слушает в ожидании новых подключений и создает новые потоки для обработки каждого из них. Асинхронные задачи позволяют нам использовать такой же подход без оверхеда потоков. Этим мы и займемся в третьей части.

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

Dyn

Мы уже знаем, как опрашивать несколько future за один раз, поскольку именно это мы делали при реализации JoinAll. Что же мы можем скопипастить?

Одна из вещей, которые необходимо изменить — тип Vec для futures. Наш JoinAll использовал Vec<Pin<Box<F>>>, где F — дженерик‑параметр типа, но в нашей основной функции нет каких‑либо параметров типа. Мы также хотим, чтобы новый вектор мог содержать futures разных типов одновременно. Нужная нам в данном случае фича Rust это динамические трейт‑объекты — dyn Trait. Начнем с алиаса типа, чтобы не писать его по несколько раз:

type DynFuture = Pin<Box<dyn Future<Output = ()>>>;

Обратите внимание, что у DynFuture нет параметров типа. Мы можем поместить любой future, упакованный в Box, до тех пор пока Output — (). Теперь, вместо создания join_future в функции main мы создадим Vec<DynFuture> и начнем называть эти futures задачами:

fn main() {
    let mut tasks: Vec<DynFuture> = Vec::new();
    for n in 1..=10 {
        tasks.push(Box::pin(foo(n)));
    }
    let waker = futures::task::noop_waker();
    let mut context = Context::from_waker(&waker);
    …

Playground #3

Мы управляем Vec<DynFuture> с помощью retain_mut, как это делал JoinAll, убирая futures из Vec, как только они возвращают Ready. Нам необходимо изменить цикл while на loop/break, чтобы выполнять опрос, проверять готовы ли мы, и затем обрабатывать Waker’ов. Теперь это выглядит так:

 let waker = futures::task::noop_waker();
    let mut context = Context::from_waker(&waker);
    loop {
        // Poll each task, removing any that are Ready.
        let is_pending = |task: &mut DynFuture| {
            task.as_mut().poll(&mut context).is_pending()
        };
        tasks.retain_mut(is_pending);

        // If there are no tasks left, we're done.
        if tasks.is_empty() {
            break;
        }

        // Otherwise handle WAKE_TIMES and sleep as in Part One...
        …

Playground #3

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

Обратите внимание, что поведение данного цикла несколько отличается от того, как работают задачи в Tokio. Обычно Rust завершает работу когда основной поток закончил работу, не дожидаясь завершения фоновых процессов, точно также Tokio завершает работу, когда основная задача завершилась, не дожидаясь фоновых задач. Однако, наша версия основного цикла продолжает работу пока все задачи не завершатся. Также она предполагает, что у задач нет возвращаемого значения. Мы исправим эти два момента, когда доберемся до JoinHandle, но сначала займемся spawn.

Spawn

Функция spawn должна добавлять новые future в Vec задач. Как стоит реализовать доступ к Vec? Было бы удобно, если бы мы могли делать то же, что делали с WAKE_TIMES и сделать TASKS глобальной переменной, защищенной Mutex, но в этот раз это не сработает. Наш основной цикл вешает лок на WAKE_TIMES по завершении опроса, но если мы сделаем TASKS глобальной, то основной цикл будет вешать лок в процессе опроса и любая задача, вызывающая spawn попадет в дедлок.

Мы обойдем это, создав два разных списка. Мы оставим tasks на своем месте — в качестве локальной переменной основного цикла, а также добавим глобальный список NEW_TASKS. Функция spawn будет добавлять задачи в NEW_TASKS:

static NEW_TASKS: Mutex<Vec<DynFuture>> = Mutex::new(Vec::new());

fn spawn<F: Future<Output = ()>>(future: F) {
    NEW_TASKS.lock().unwrap().push(Box::pin(future));
}

Playground #4

Теперь основной цикл может… погодите‑ка, оно не компилируется:

error[E0277]: `(dyn Future<Output = ()> + 'static)` cannot be sent between threads safely
    --> tasks_no_send_no_static.rs:43:19
     |
43   | static NEW_TASKS: Mutex<Vec<DynFuture>> = Mutex::new(Vec::new());
     |                   ^^^^^^^^^^^^^^^^^^^^^ `(dyn Future<Output = ()> + 'static)` cannot be sent between threads
     |
     = help: the trait `Send` is not implemented for `(dyn Future<Output = ()> + 'static)`, which is required by
             `Mutex<Vec<Pin<Box<(dyn Future<Output = ()> + 'static)>>>>: Sync`

Глобальные переменные в Rust должны быть Sync, а Mutex<T> является Sync только когда T является Send. DynFuture должен обещать, что он реализует Send:

type DynFuture = Pin<Box<dyn Future<Output = ()> + Send>>;

Итак, теперь… не‑а, все еще не собирается:

error[E0277]: `F` cannot be sent between threads safely
  --> src/main.rs:46:36
   |
46 |     NEW_TASKS.lock().unwrap().push(Box::pin(future));
   |                                    ^^^^^^^^^^^^^^^^ `F` cannot be sent between threads safely
   |
   = note: required for the cast from `Pin<Box<F>>` to
           `Pin<Box<(dyn futures::Future<Output = ()> + std::marker::Send + 'static)>>`

Playground #5

Справедливо, spawn должна делать то же самое:

fn spawn<F: Future<Output = ()> + Send>(future: F) { … }

Ну что, доволен? Не‑а:

error[E0310]: the parameter type `F` may not live long enough
  --> src/main.rs:46:36
   |
46 |     NEW_TASKS.lock().unwrap().push(Box::pin(future));
   |                                    ^^^^^^^^^^^^^^^^
   |                                    |
   |                                    the parameter type `F` must be valid for the static lifetime...
   |                                    ...so that the type `F` will meet its required lifetime bounds

Playground #6

Глобальные переменные имеют лайфтайм 'static, а значит они не могут держать указатели на что‑либо, что может быть освобождено. Трейт‑объекты, такие как DynFuture по умолчанию 'static, а вот типы параметров — такие как F — нет. Если spawn хочет поместить F в глобальную переменную, она должна обещать, что F — 'static:

fn spawn<F: Future<Output = ()> + Send + 'static>(future: F) { … }

Наконец‑то оно компилируется. Многовато телодвижений просто чтобы создать глобальный Vec, давайте подумаем, что именно мы создали: вместо «Vec с futures», NEW_TASKS это «Vec потоко‑безопасных futures, не содержащих потенциально подвешенных указателей». У Rust нет сборщика мусора, так что подвешенные указатели привели бы к багам повреждения памяти и возможность сказать, что мы не хотим этого — неплохая вещь.

Итак…теперь основной цикл может перемещать задачи из NEW_TASKS в tasks. Это не требует много кода, но есть пара моментов, которые стоит учитывать, и в этот раз это баги рантайма вместо ошибок компиляции. В первую очередь, нам нужно опрашивать новые задачи до следующей итерации основного цикла, чтобы у них был шанс запросить пробуждение перед сном. Также нам надо убедиться, что NEW_TASKS разблокирована перед опросом, иначе у нас снова возникнет дедлок, которого мы старались избежать. Вот расширенный основной цикл:

loop {
    // Poll each task, removing any that are Ready.
    let is_pending = |task: &mut DynFuture| {
        task.as_mut().poll(&mut context).is_pending()
    };
    tasks.retain_mut(is_pending);

    // Collect new tasks, poll them, and keep the ones that are Pending.
    loop {
        let Some(mut task) = NEW_TASKS.lock().unwrap().pop() else {
            break;
        };
        // Polling this task could spawn more tasks, so it's important that
        // NEW_TASKS isn't locked here.
        if task.as_mut().poll(&mut context).is_pending() {
            tasks.push(task);
        }
    }

    // If there are no tasks left, we're done.
    if tasks.is_empty() {
        break;
    }

    // Otherwise handle WAKE_TIMES and sleep as in Part One...
    …

Playground #7

Подготовив почву, мы можем задать функцию async_main и передать ей бразды запуска задач вместо хардкодинга списка задач в main:

async fn async_main() {
    // The main loop currently waits for all tasks to finish.
    for n in 1..=10 {
        spawn(foo(n));
    }
}

fn main() {
    let waker = futures::task::noop_waker();
    let mut context = Context::from_waker(&waker);
    let mut tasks: Vec<DynFuture> = vec![Box::pin(async_main())];
    …

Playground #7

Оно работает! Из‑за того, как мы добавляем и убираем задачи в и из NEW_TASKS, порядок вывода отличается. Мы могли бы исправить это, но давайте оставим так. Это хорошее напоминание, что подобно потокам, одновременно выполняемые задачи могут выполняться в любом порядке.

JoinHandle

Как мы уже упоминали ранее, Tokio поддерживает фоновые задачи, которые не блокируют завершение программы, а также есть поддержка возврата значений у задач. Обе фичи требуют от tokio::task::spawn возвращать tokio::task::JoinHandle подобно тому, как thread::spawn возвращает thread::JoinHandle. Для того, чтобы тоже иметь данный функционал мы реализуем наш собственный JoinHandle. Также, поскольку до этого мы сталкивались с блокированием только в контексте sleep, мы познакомимся с новой формой блокирования и необычным багом, проистекающим из нее.

JoinHandle должен общаться между двумя задачами: одна в процессе завершения, другая — ждет завершения первой. Ожидающей задаче нужно куда‑то поместить свой Waker, чтобы завершающая могла его выполнить, а завершающей задаче в свою очередь надо куда‑то передать возвращаемое значение T, чтобы ожидающая задача могла его получить. Они не нужны нам одновременно, так что мы можем использовать enum. Он должен давать общий доступ и быть изменяем, так что обернем его в Arc и Mutex:

enum JoinState<T> {
    Unawaited,
    Awaited(Waker),
    Ready(T),
    Done,
}

struct JoinHandle<T> {
    state: Arc<Mutex<JoinState<T>>>,
}

Playground #8

Ожидание завершения задачи будет происходить за счет ожидания JoinHandle, так что последнему в свою очередь необходимо реализовать Future. Загвоздка в том, что ожидающий поток хочет владеть переданным в JoinState::Ready(T) значением T, но Arc<Mutex<JoinState>> позволяет обращаться к JoinState только по ссылке, так что мы не можем переместить T и «оставить дыру» там, куда ведет ссылка. Вместо этого мы заменим весь JoinState, используя mem::replace:

impl<T> Future for JoinHandle<T> {
    type Output = T;

    fn poll(self: Pin<&mut Self>, context: &mut Context) -> Poll<T> {
        let mut guard = self.state.lock().unwrap();
        // Use JoinState::Done as a placeholder, to take ownership of T.
        match mem::replace(&mut *guard, JoinState::Done) {
            JoinState::Ready(value) => Poll::Ready(value),
            JoinState::Unawaited | JoinState::Awaited(_) => {
                // Replace the previous Waker, if any.
                *guard = JoinState::Awaited(context.waker().clone());
                Poll::Pending
            }
            JoinState::Done => unreachable!("polled again after Ready"),
        }
    }
}

Playground #8

Передаваемые spawn future ничего не знают о JoinState, так что нам будет нужна обертка для обработки возвращаемых значений и запуска Waker при его наличии:

async fn wrap_with_join_state<F: Future>(
    future: F,
    join_state: Arc<Mutex<JoinState<F::Output>>>,
) {
    let value = future.await;
    let mut guard = join_state.lock().unwrap();
    if let JoinState::Awaited(waker) = &*guard {
        waker.wake_by_ref();
    }
    *guard = JoinState::Ready(value)
}

Playground #8

Теперь мы можем создать JoinState и использовать нашу обертку в spawn, чтобы принимался любой тип вывода и возвращался JoinHandle:

fn spawn<F, T>(future: F) -> JoinHandle<T>
where
    F: Future<Output = T> + Send + 'static,
    T: Send + 'static,
{
    let join_state = Arc::new(Mutex::new(JoinState::Unawaited));
    let join_handle = JoinHandle {
        state: Arc::clone(&join_state),
    };
    let task = Box::pin(wrap_with_join_state(future, join_state));
    NEW_TASKS.lock().unwrap().push(task);
    join_handle
}

Playground #8

Мы будем собирать и применять .await к JoinHandle’ам в async_main, подобно тому как мы обрабатывали задачи Tokio ранее:

async fn async_main() {
    let mut task_handles = Vec::new();
    for n in 1..=10 {
        task_handles.push(spawn(foo(n)));
    }
    for handle in task_handles {
        handle.await;
    }
}

Playground #8

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

fn main() {
    let waker = futures::task::noop_waker();
    let mut context = Context::from_waker(&waker);
    let mut main_task = Box::pin(async_main());
    let mut other_tasks: Vec<DynFuture> = Vec::new();
    loop {
        // Poll the main task and exit immediately if it's done.
        if main_task.as_mut().poll(&mut context).is_ready() {
            return;
        }
        // Poll other tasks and remove any that are Ready.
        let is_pending = |task: &mut DynFuture| {
            task.as_mut().poll(&mut context).is_pending()
        };
        other_tasks.retain_mut(is_pending);
        // Handle NEW_TASKS and WAKE_TIMES...

Playground #8

Готово! Мы сделали много изменений за раз и, к счастью, оно собирается. Даже почти работает. Программа выводит корректный текст, но после этого ловит панику:

…
end 3
end 2
end 1
thread 'main' panicked at src/main.rs:143:50:
sleep forever?

Playground #8

Это и есть тот самый интересный баг, который мы ждали.

Waker

Паника возникает на этой строке, которая была в основном цикле с первой части:

let next_wake = wake_times.keys().next().expect("sleep forever?");

Playground #8

Цикл собирается уйти в sleep, так что он запрашивает следующее время пробуждения, но древо WAKE_TIMES пустое. Раньше мы могли предполагать, что если какая‑то из задач возвращала Pending, то должно существовать хотя бы одно время пробуждения, поскольку единственным источником блокировки был Sleep. Но теперь у нас есть второй источник: JoinHandle. Если JoinHandle в Pending, это может быть вызвано тем, что другая задача спит и задала время пробуждения. Но также это может произойти когда другая задача готова вернуть Ready, но мы еще не опросили ее. Это сильно зависит от порядка задач в списке. Если задача в начале списка ждет задачу из конца, мы можем оказаться в ситуации с задачами в Pending и без запланированных пробуждений.

Именно это и произошло. Основная задача скорее всего блокируется на первом JoinHandle. Основной цикл пробуждается, опрашивает основную задачу и этот JoinHandle все еще в Pending. После чего он опрашивает все задачи из other_tasks. Каждая из них выводит сообщение о завершении, отправляет сигнал своему JoinHandle и возвращает Ready. К этому моменту нам нужно опросить основную задачу вместо попытки сна. Как же нам передать это основному циклу? Мы могли бы создать еще один static‑флаг, но есть вариант получше. Мы используем наш Waker.

Мы еще с первой части использовали futures::task::noop_waker для передачи пустого Waker. Когда Sleep был единственным источником блокировки, у наших задач не было способа разблокировать другие задачи и все, что нам нужно было от Waker — заглушка, чтобы код компилировался. Но ситуация поменялась. Наша функция wrap_with_join_state уже вызывает Waker'ов корректно по завершении выполнения задач и было бы неплохо знать, когда это происходит. Почему бы не написать собственный Waker?

Waker реализует трейт From<Arc<W>>, где W — любой тип с трейтом Wake, который в свою очередь требует наличия метода wake. Этот метод принимает Arc<Self>, что немного забавно, но в целом позволяет нам делать что угодно. Самый простой вариант — сделать что‑то вроде Arc<Mutex<bool>> и делать его true когда какая‑либо задача получила запрос на пробуждение. Это не сильно отличается от static‑флага, но дает чужим future'ам вызывать наш Waker без необходимости знать реализацию основного цикла. Вот наш «bool»:

struct AwakeFlag(Mutex<bool>);

impl AwakeFlag {
    fn check_and_clear(&self) -> bool {
        let mut guard = self.0.lock().unwrap();
        let check = *guard;
        *guard = false;
        check
    }
}

impl Wake for AwakeFlag {
    fn wake(self: Arc<Self>) {
        *self.0.lock().unwrap() = true;
    }
}

Playground #9

Мы можем создать AwakeFlag и затем из него Waker в начале main:

fn main() {
    let awake_flag = Arc::new(AwakeFlag(Mutex::new(false)));
    let waker = Waker::from(Arc::clone(&awake_flag));
    let mut context = Context::from_waker(&waker);
    …

Playground #9

И, наконец, мы можем добавить ту самую проверку в основной цикл:

// Collect new tasks, poll them, and keep the ones that are Pending.
loop {
    let Some(mut task) = NEW_TASKS.lock().unwrap().pop() else {
        break;
    };
    if task.as_mut().poll(&mut context).is_pending() {
        other_tasks.push(task);
    }
}
// Some tasks might wake other tasks. Re-poll if the AwakeFlag has been
// set. Polling futures that aren't ready yet is inefficient but allowed.
if awake_flag.check_and_clear() {
    continue;
}
// Otherwise handle WAKERS and sleep as in Part One...

Playground #9

Работает! Мы успешно реализовали задачи.

Пришло время пойти дальше и вместо сна и вывода текста на экран взглянуть на реальные ввод‑вывод и использовать spawn для обработки сетевых соединений.

Комментарии (25)


  1. ednersky
    28.10.2024 19:46

    Парадигма async/awain (stackless корутины/процессы) - худшее, что придумано в мире асинхронного программирования. Ладно для Rust, который новый язык, ему не повредит. Наверное.

    Но, например, в Python эта парадигма много вреда нанесла.

    Чем async/await плохи? Тем, что блокируют возможность развития простого синхронного кода в сложный асинхронный методом переписывания ТОЛЬКО сетевой подсистемы.

    Вот был, например, python с его библиотекой requests. Затем появился asyncio. Поскольку не было никакой возможности писать совместимый код (один появившийся async заставляет "промазывать" async'ами весь каскад всех вызовов, от низкого уровня до высокого), то в итоге на asyncio всё-всё-всё вместо переиспользования - переписывали.

    А вот внедрение async/await в JS, например, прошло сравнительно безболезненно, поскольку до них использовали колбеки.

    Что насчёт синхронного, но ходящего в сеть, кода для Rust? Он существует? Его много?


    1. fenrir1121
      28.10.2024 19:46

      В Rust вы вольны использовать ту реализацию, которая больше нравится. Используйте акторы (https://github.com/actix/actix) или CSP (https://github.com/fereidani/kanal) или напишите что-то свое.

      Что насчёт синхронного, но ходящего в сеть, кода для Rust? Он существует? Его много?

      C10k на современном железе выглядит скорее как C100k или даже C1m. По крайней мере с обработкой 10 тысяч потоков справляется ноутбук, с которого я сейчас пишу. Конечно будут накладные расходы на переключения и async версия потребляет существенно меньше CPU.
      Аналог requests в rust - reqwest имеет и синхронный и асинхронный апи, но асинхронный является основным.


      1. ednersky
        28.10.2024 19:46

        В Rust вы вольны использовать ту реализацию, которая больше нравится.

        это хорошо свободой, анархией, но плохо тем, что вредит переиспользованию кода.

        если бы на уровне языка (а не библиотек) поддержан был бы какой-то из паттернов изначально, то все библиотеки на этом паттерне бы и строили

        Аналог requests в rust - reqwest имеет и синхронный и асинхронный апи, но асинхронный является основным.

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

        В случае с async/await вам придётся переписывать все 100500 кода.

        В случае со стекфул парадигмой (либо stackless, но доведённой до уровня компилятора) вам придётся переписать только сетевую часть вашей библиотеки (что может быть менее одного процента в этих 100500).

        Я не критикую Rust, я критикую async/await подход, вернее даже конкретные его реализации.

        Теоретически можно спустить это на уровень компилятора. Удалить ключевое слово async (вернее сделать чтобы оно не требовалось в декларации функции, а было бы аналогом spawn или go из golang), а await сделать возможным к появлению в любом месте. Тогда появится возможность исправлять синхронный код к асинхронному без переписывания его зависимостей, но, увы, для этого парадигма должна входить в сам язык.


        1. fenrir1121
          28.10.2024 19:46

          В случае со стекфул парадигмой (либо stackless, но доведённой до уровня компилятора) вам придётся переписать только сетевую часть вашей библиотеки (что может быть менее одного процента в этих 100500).

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

          Написать аналог планировщика, который будет раскидывать задачи можно и они есть, а разницы будет он вызываться как `await func`, `go func`, или `spawn func` не очень много. Посмотрите, например, на https://github.com/crossbeam-rs/, он позволяет писать код похожий на гошный

          Скрытый текст
          use crossbeam::channel;
          use std::thread;
          use std::time::Duration;
          
          fn main() {
              let (sender, receiver) = channel::unbounded();
          
              for i in 0..5 {
                  let s = sender.clone();
                  thread::spawn(move || {
                      thread::sleep(Duration::from_secs(1));
                      s.send(i).expect("Failed to send data");
                  });
              }
          
              for received in receiver.iter().take(5) {
                  println!("Received: {}", received);
              }
          }


          1. ednersky
            28.10.2024 19:46

            Есть языки, где эти попытки были максимально продвинуты. Например Марк Леман внедрял в Perl библиотеку Coro. При этом он же имплементировал LWP::Coro (или Coro::LWP - давно это было, уже не помню точно) так, что 100500 наработок на LWP (а тогда на нём делали чуть ли не 100% парсеров интернета) просто начинали работать асинхронно, не подозревая об этом.

            Получалось, что контекст переключается около сисколлов на чтение/ожидание сокета и это довольно хорошо.

            Кроме того, если говорить о Golang, то у него горутины изначально. А потому понятие "писать синхронный код" для него тождественно "преодолевать трудности". Да есть mutex, но изначальная парадигма такова, что все по ней и идут.

            JS, как я сказал выше, имел только колбечный набор накопленных библиотек, а колбеки всегда совместимы с любой формой асинхронщины, а потому JS подобных трудностей не испытывает: новые либы люди пишут на async/await, а старые на колбеках/промисах.

            Увы, Rust, несмотря на то, что молодой язык, об этом не подумали и у них снова зоопарк. Поскольку из Rust условно говоря "легко достучаться до C", то и до сисколов так же легко достучаться, то есть легко наполучить кучу либ рассчитанных на однопоток + синхру. Жаль, что разработчики не проработали этот момент.


            1. fenrir1121
              28.10.2024 19:46

              Есть языки, где эти попытки были максимально продвинуты. Например Марк Леман внедрял в Perl библиотеку Coro.

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

              Кроме того, если говорить о Golang, то у него горутины изначально. А потому понятие "писать синхронный код" для него тождественно "преодолевать трудности". Да есть mutex, но изначальная парадигма такова, что все по ней и идут.

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

              Увы, Rust, несмотря на то, что молодой язык, об этом не подумали и у них снова зоопарк.

              Скорее в первую очередь думали не об этом. Он проектировался с возможностью работать на голом железе без ОС как замена C и этом случае недоступна даже стандартная библиотека. А так если нужно есть async_std, но да он появился не сразу.


        1. Pavel_Agafonov
          28.10.2024 19:46

          В Rust работают над решением проблемы цветных фукнций.


    1. pinklifeart
      28.10.2024 19:46


      1. ednersky
        28.10.2024 19:46

        Вот да. Именно об этой проблеме я говорю! Спасибо за мем!


    1. mayorovp
      28.10.2024 19:46

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

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

      И если язык успел просуществовать без этих самых обёрток хоть какое-то время - в его экосистеме неминуемо расплодились программы и библиотеки, которые активно используют "голые" системные вызовы через ffi.

      Кстати, ffi тоже нужно проектировать в гипотетическом языке с M:N параллелизмом с осторожностью, поскольку внешний мир про этот самый параллелизм-то не знает. В частности, большинство языков, включая Python, были "обречены" на async/await именно из-за слишком свободного ffi и необходимости сохранять обратную совместимость с нативными модулями.


      1. ednersky
        28.10.2024 19:46

        Потому что в противном случае любая библиотека, случайно либо намеренно обошедшая эту самую сетевую подсистему, ломает всё.

        да, но нет.

        смотрите, например у нас есть большой пул роботов, качающих что-то из интернета и складывающих результаты работы в БД. Переписать придётся хождение в интернет и хождение в БД.

        Как правило, большинство вопросов "как скачать страничку из интернет" или "как сделать запрос к БД" для языка X дают 1 mainflow которым будет пользоваться большинство.

        А когда появится второй flow, в этот момент будет стоять вопрос совместимости с mainflow. Так вот когда во втором flow появляется async/await приходится переписывать ВСЁ.

        Только в одном языке появление второго flow прошло сравнительно успешно - JS, потому что первый flow был на колбеках, а колбеки совместимы с ЛЮБЫМ асинхронным движком


        1. mayorovp
          28.10.2024 19:46

          Лучше переписанное всё, чем нерабочее и глючное всё.

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


          1. ednersky
            28.10.2024 19:46

            сравните:

            • переписать сетевую библиотеку

            • переписать ВСЁ (картинка ниже), включая парсеры и проч


            1. mayorovp
              28.10.2024 19:46

              Но у вас нет опции "переписать сетевую библиотеку", потому что у вас, блин, в языке может вообще не быть сетевой библиотеки! И даже если есть - нет никакой гарантии, что другие библиотеки пользуются именно ей!

              Возьмём для примера ваш любимый Python. Для работы с сетью там предлагались минимум три библиотеки - языковая, биндин к openssl, и биндинг к curl. От переписывания первой с учётом гринтредов у вас ни openssl, ни curl автоматически не заработают!

              А в системных языках (Си, С++, Rust) всё ещё хуже, потому что там этих "сетевых библиотек" бесконечно много.


              1. ednersky
                28.10.2024 19:46

                Но у вас нет опции "переписать сетевую библиотеку", потому что у вас, блин, в языке может вообще не быть сетевой библиотеки!

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

                обычно библиотеки ходящие в сеть не представлены в большом множестве.

                От переписывания первой с учётом гринтредов у вас ни openssl, ни curl автоматически не заработают!

                это почему это?

                А в системных языках (Си, С++, Rust) всё ещё хуже, потому что там этих "сетевых библиотек" бесконечно много.

                в Си и С++ никогда не было единого репа "где взять либу". Там всегда бардак был на эту тему: иди по сайтам копайся, ищи.

                в Rust есть. И поиском "как запаковать json/yaml" или "как установить TCP-соединение" человек придёт к 1-2 вариантам.


      1. ednersky
        28.10.2024 19:46

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

        речь идёт о переписывании синхронного кода в асинхронный. в синхронном коде обычно нет такого понятия, как "примитивы синхронизации". Они добавятся как раз при этом переписывании и внутрь библиотеки (библиотек) которые ранее выполняли сетевое взаимодействие. Как правило это всего два-три места: хождение в интернет + хождение в 1-2 БД


        1. mayorovp
          28.10.2024 19:46

          в синхронном коде обычно нет такого понятия, как "примитивы синхронизации"

          А это тогда что?

          https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-waitforsingleobject
          https://en.cppreference.com/w/cpp/thread/mutex
          https://doc.rust-lang.org/std/sync/struct.Mutex.html


          1. ednersky
            28.10.2024 19:46

            а это уже мультитредовый код.

            такое ОЧЕНЬ редко встречается на продах. чаще всего множество single-thread приложений, каждое из которых синхронное или асинхронное.


            1. mayorovp
              28.10.2024 19:46

              У кого редко, а у кого вообще все программы многопоточные.


              1. ednersky
                28.10.2024 19:46

                многопоточные программы (в смысле процессов или тредов, а не асинхронных процессов) как правило (не в 100% случаев, но как правило) признак некомпетентности разработчика. У многопотока крайне узкая ниша, вроде выноса вычислений в другой поток итп

                То что многопоточные сервера неэффективны, все выяснили примерно в 200x - именно тогда пошёл бум ухода от многопотоков к однопотокам с асинхронной парадигмой, навроде nginx


      1. ednersky
        28.10.2024 19:46

        В частности, большинство языков, включая Python, были "обречены" на async/await именно из-за слишком свободного ffi и необходимости сохранять обратную совместимость с нативными модулями.

        ЕМНИП в Python была (есть?) реализация гринтредов, без ключевых слов async/await.

        Просто почему-то решили на борт, в stdlib взять именно async/await, а не эту машину.


        1. mayorovp
          28.10.2024 19:46

          Эта реализация гринтредов была в отдельном интерпретаторе, который Stackless Python. Интерпретатор невозможно просто взять и "затащить" в стандартную библиотеку.


          1. ednersky
            28.10.2024 19:46

            но на переходе к версии 3 от версии 2 они могли это сделать. async/await ЕМНИП появился именно в версии 3, причём не в 3.0


            1. mayorovp
              28.10.2024 19:46

              Переход от версии 2 к версии 3 был не настолько глобальным, там поменялся, главным образом, парсер. Странно говорить это про изменение, расколовшее экосистему надвое, но это очень простое изменение.

              Переход на гринтреды куда более всеобъемлющ, и требует изменения API нативных модулей. А никто не хотел переписывать все нативные молдули, переписать все библиотеки оказалось проще.


              1. ednersky
                28.10.2024 19:46

                Переход на гринтреды куда более всеобъемлющ, и требует изменения API нативных модулей.

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