Как работает libuv * неблокирование * точно? - PullRequest
6 голосов
/ 11 апреля 2020

Итак, я только что обнаружил, что libuv - довольно небольшая библиотека по сравнению с C библиотеками go (сравните с FFmpeg). Последние 6 часов я потратил на чтение исходного кода, чтобы прочувствовать событие l oop на более глубоком уровне. Но все еще не вижу, где реализована «неблокировка». Где в кодовой базе вызывается какой-либо сигнал прерывания события или еще много чего.

Я использую Node.js уже более 8 лет, поэтому я знаком с тем, как использовать асин c неблокирующее событие l oop, но на самом деле я никогда не смотрел на реализацию.

Мой вопрос состоит из двух частей:

  1. Где точно означает "цикл" "происходит в libuv?
  2. Какие ключевые шаги в каждой итерации l oop делают его неблокирующим и asyn c.

Итак, мы начнем с примера "Привет, мир!" Все, что требуется, это:

#include <stdio.h>
#include <stdlib.h>
#include <uv.h>

int main() {
  uv_loop_t *loop = malloc(sizeof(uv_loop_t));
  uv_loop_init(loop); // initialize datastructures.
  uv_run(loop, UV_RUN_DEFAULT); // infinite loop as long as queue is full?
  uv_loop_close(loop);
  free(loop);
  return 0;
}

Ключевой функцией, которую я изучал, является uv_run. Функция uv_loop_init, по сути, инициализирует структуры данных, поэтому я не думаю, что это слишком много. Но настоящее волшебство c, кажется, происходит с uv_run, где-то . Набор высокоуровневых фрагментов кода из репозитория libuv - в этой сущности , показывающий, что вызывает функция uv_run.

По сути, это сводится к следующему:

while (NOT_STOPPED) {
  uv__update_time(loop)
  uv__run_timers(loop)
  uv__run_pending(loop)
  uv__run_idle(loop)
  uv__run_prepare(loop)
  uv__io_poll(loop, timeout)
  uv__run_check(loop)
  uv__run_closing_handles(loop)
  // ... cleanup
}

Эти функции находятся в сущности.

  • uv__run_timers: запускает обратные вызовы таймера? циклы с for (;;) {.
  • uv__run_pending: выполняет регулярные обратные вызовы? проходит по очереди с while (!QUEUE_EMPTY(&pq)) {.
  • uv__run_idle: без исходного кода
  • uv__run_prepare: без исходного кода
  • uv__io_poll: есть ли опрос? (не могу сказать, что это значит, хотя) Имеет 2 цикла: while (!QUEUE_EMPTY(&loop->watcher_queue)) { и for (;;) {,

И тогда мы закончили. И программа существует, потому что «работы» не выполняются.

Так что я думаю, что после всей этой копки я ответил на первую часть моего вопроса, и цикл специально для этих 3 функций:

  1. uv__run_timers
  2. uv__run_pending
  3. uv__io_poll

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

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

Не будучи экспертом C, for (;;) { блокирует ли событие l oop? Или это может выполняться бесконечно, и каким-то образом переходить к другим частям кода из системных событий ОС или что-то в этом роде?

Так uv__io_poll вызывает poll(...) в этом бесконечном l oop. Я не думаю, что неблокирует, это правильно? Похоже, это все, что он делает в основном.

Рассматривая kqueue.c, также существует uv__io_poll, поэтому я предполагаю, что реализация poll является резервной и используется kqueue на Ma c что неблокирует?

Так это что? Это просто зацикливание в uv__io_poll и каждой итерации, которую вы можете добавить в очередь, и до тех пор, пока в очереди есть какие-то вещи, которые она будет выполнять? Я до сих пор не понимаю, как это неблокирует и асинхронно c.

Может ли кто-нибудь описать, как это асин c и неблокирует, и какие части кода взять смотреть на? По сути, я хотел бы увидеть, где в libuv существует «свободное бездействие процессора». Где когда-либо свободен процессор при звонке на наш начальный uv_run? Если он бесплатный, как он вызывается повторно, как обработчик событий? (Как обработчик событий браузера от мыши, прерывание). Я чувствую, что ищу прерывание, но не вижу его.

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

Ответы [ 3 ]

6 голосов
/ 30 апреля 2020

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

(Обратите внимание, что когда я говорю «в C», я на самом деле имею в виду «в интерфейсе системных вызовов или рядом с ним, где пользовательское пространство встречается с ядром».)

Все разные бэкэнды (select, poll, epoll и др. c) более или менее являются вариациями одной и той же темы. Они блокируют текущий процесс или поток до тех пор, пока не будет выполнена работа, например, обслуживание таймера, чтение из сокета, запись в сокет или обработка ошибки сокета.

Когда текущий процесс заблокирован, он буквально не получает циклов ЦП, назначенных ему планировщиком ОС.

Часть проблемы, стоящей за пониманием этого материала, IMO - плохая терминология: asyn c, syn c в JS -land , которые на самом деле не описывают, что это за вещи. На самом деле, в C мы говорим о неблокирующем или блокирующем вводе / выводе.

Когда мы читаем из дескриптора блокирующего файла, процесс (или поток) блокируется - запрещен запуск - - пока ядру не будет что почитать; когда мы записываем в дескриптор блокирующего файла, процесс блокируется до тех пор, пока ядро ​​не примет весь буфер.

При неблокирующем вводе-выводе он точно такой же, за исключением того, что ядро ​​не остановит процесс работает, когда нечего делать: вместо этого, когда вы читаете или пишете, он сообщает вам, сколько вы прочитали или написали (или произошла ошибка).

Системный вызов select (и друзья) предотвращают C разработчику от необходимости снова и снова читать неблокирующий файловый дескриптор - select (), по сути, является системным вызовом блокировки, который разблокирует, когда любой из дескрипторов или таймеров, которые вы смотрите, готов. Это позволяет разработчику создать oop вокруг select, обслуживая любые события, о которых он сообщает, например, истекшее время ожидания или дескриптор файла, который можно прочитать. Это событие l oop.

Итак, по сути, то, что происходит на C конце JS события l oop, примерно так Алгоритм:

while(true) {
  select(open fds, timeout);
  did_the_timeout_expire(run_js_timers());
  for (each error fd)
    run_js_error_handler(fdJSObjects[fd]);
  for (each read-ready fd)
    emit_data_events(fdJSObjects[fd], read_as_much_as_I_can(fd));
  for (each write-ready fd) {
    if (!pendingData(fd))
      break;
    write_as_much_as_I_can(fd);
    pendingData = whatever_was_leftover_that_couldnt_write; 
  }
}

FWIW - я действительно написал событие l oop для v8 на основе select (): это действительно так просто.

Важно также помнить, что JS всегда работает до завершения. Итак, когда вы вызываете функцию JS (через API v8) из C, ваша программа C ничего не делает, пока не вернется код JS.

NodeJS использует некоторые Оптимизации, такие как обработка ожидающих записей в отдельных pthreads, но все это происходит в «C space», и вы не должны думать / беспокоиться о них, пытаясь понять этот шаблон, потому что они не актуальны.

Вы также можете быть одурачены мыслью, что JS не работает до конца при работе с такими вещами, как функции asyn c - но это действительно так, 100% времени - если вы не до На этой скорости, сделайте некоторое чтение относительно события l oop и очереди микро-задач. Asyn c функции - это, по сути, синтаксическая хитрость, и их «завершение» подразумевает возврат Promise.

4 голосов
/ 24 апреля 2020

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

Тем не менее, заглянем в src/unix/kqueue.c раскрывает некоторую внутреннюю механику обработки событий:

int uv__io_check_fd(uv_loop_t* loop, int fd) {
  struct kevent ev;
  int rc;

  rc = 0;
  EV_SET(&ev, fd, EVFILT_READ, EV_ADD, 0, 0, 0);
  if (kevent(loop->backend_fd, &ev, 1, NULL, 0, NULL))
    rc = UV__ERR(errno);

  EV_SET(&ev, fd, EVFILT_READ, EV_DELETE, 0, 0, 0);
  if (rc == 0)
    if (kevent(loop->backend_fd, &ev, 1, NULL, 0, NULL))
      abort();

  return rc;
}

Опрос файлового дескриптора выполняется здесь, «устанавливая» событие с помощью EV_SET (аналогично тому, как вы используйте FD_SET перед проверкой с помощью select()), и обработка выполняется с помощью обработчика kevent.

Это специфицирует c для событий стиля kqueue (в основном используется на BSD-лайках а-ля MacOS), и есть много других реализаций для разных Unices, но все они используют одно и то же имя функции для выполнения неблокирующих проверок ввода-вывода. Смотрите здесь для другой реализации, использующей epoll.

Чтобы ответить на ваши вопросы:

1) Где именно происходит "зацикливание" в libuv?

Структура данных QUEUE используется для хранения и обработки событий. Эта очередь заполняется платформой и IO-, указанными c типами событий, которые вы регистрируете для прослушивания. Внутренне он использует умный связанный список, использующий только массив из двух void * указателей ( см. Здесь ):

typedef void *QUEUE[2];

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

Как только в очереди есть файловые дескрипторы, которые генерируют данные, асинхронный I Код / O, упомянутый ранее, подхватит его. backend_fd в структуре uv_loop_t является генератором данных для каждого типа ввода / вывода.

2) Каковы ключевые шаги в каждой итерации l oop, которые делают это неблокирующее и асинхронное c?

libuv по сути является оберткой (с хорошим API) для реальных рабочих лошадок, а именно kqueue, epoll, select, et c. Чтобы полностью ответить на этот вопрос, вам понадобится немало знаний в реализации файлового дескриптора уровня ядра, и я не уверен, что это именно то, что вам нужно, основываясь на этом вопросе.

Короткий ответ: все базовые операционные системы имеют встроенные средства для неблокирующего (и, следовательно, асинхронного ввода-вывода c). Я думаю, то, как работает каждая система, немного выходит за рамки этого ответа, но я оставлю некоторое чтение для любопытных:

https://www.quora.com/Network-Programming-How-is-select-implemented?share=1

0 голосов
/ 27 апреля 2020

Первое, что нужно иметь в виду, это то, что работа должна быть добавлена ​​в очереди libuv с использованием ее API; нельзя просто загрузить libuv, запустить его основной l oop, а затем кодировать некоторые операции ввода-вывода и получить asyn c I / O.

Очереди, поддерживаемые libuv, управляются с помощью цикла. Бесконечное l oop в uv__run_timers на самом деле не бесконечно; обратите внимание, что первая проверка подтверждает, что таймер с самым коротким сроком действия существует (предположительно, если список пуст, это NULL), и если нет, ломает l oop и функция возвращает. Следующая проверка ломает l oop, если текущий (самый быстро истекающий) таймер не истек. Если ни одно из этих условий не нарушает l oop, код продолжается: он перезапускает таймер, вызывает его обработчик тайм-аута, а затем повторяет цикл, чтобы проверить больше таймеров. В большинстве случаев, когда этот код запускается, он ломает l oop и завершается, что позволяет запускать другие циклы.

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

...