Многопоточность в Node.js может показаться сложной темой, особенно когда речь идет о безопасной работе с разделяемой памятью. В JavaScript нет встроенной поддержки многопоточности, и разработчики Node.js обычно привыкают работать с одним потоком. Однако с появлением модуля worker_threads, который позволяет создавать несколько потоков, возникла необходимость решать проблемы взаимодействия между потоками, в частности, при совместном использовании данных. Павел Романов рассказал, как это сделать с помощью Atomics.
Разделение памяти между несколькими потоками
Разделяемая память – механизм, с помощью которого несколько потоков могут обращаться к одним и тем же данным. В Node.js для этого используется объект SharedArrayBuffer. Этот объект позволяет потокам совместно использовать определенные данные, поскольку у него одна область памяти, доступная из всех потоков, что позволяет избежать дублирования данных и уменьшает затраты на память.
Когда объект типа ArrayBuffer передается от основного потока к независимому (Worker), он копируется, и каждый поток получает свою копию данных, то есть каждый поток работает с отдельной памятью. Но SharedArrayBuffer, в отличие от ArrayBuffer, позволяет нескольким потокам обращаться к одной и той же области памяти, создавая общие данные для всех потоков.
Состояние гонки
Когда несколько потоков одновременно работают с одной и той же областью памяти, возникает проблема – состояние гонки. Это ситуация, при которой два или более потока одновременно пытаются изменить одно и то же значение. Из-за этого итоговый результат может быть непредсказуемым. Например, рассмотрим такой код:
В этом примере создаются два потока, которые одновременно пытаются записать свое значение в первый элемент typedArray. Ожидается, что каждый поток запишет свое значение threadId в общий массив. Однако при выполнении этого кода могут возникнуть неожиданные результаты – оба потока будут показывать одно и то же значение, даже если у них разные threadId:
Это происходит потому, что оба потока обращаются к одному и тому же элементу массива без контроля над очередностью их выполнения.
Использование Atomics для предотвращения гонки
Единственный 100% надежный способ избежать состояния гонки – использование встроенного JavaScript-объекта Atomics. Основная цель Atomics – обеспечить выполнение операций над разделяемыми ресурсами как единых, неделимых действий (атомарных операций). Это означает, что операция выполняется целиком и не может быть прервана другим потоком. Давайте перепишем код с использованием Atomics:
Здесь вместо обычной операции записи typedArray[0] = threadId используется Atomics.store, что делает операцию записи атомарной. Теперь можно быть уверенным, что каждое присваивание выполняется как единая операция, и другой поток не сможет прервать этот процесс:
Основные атомарные операции
Когда память разделена, несколько потоков могут одновременно читать и записывать одни и те же данные. Атомарные операции обеспечивают предсказуемость при записи и чтении данных, гарантируя, что каждая операция завершится до начала следующей и не будет прервана другим потоком:
- Atomics.add() прибавляет заданное значение к текущему значению в указанной ячейке массива и возвращает предыдущее значение.
- Atomics.and() выполняет побитовое И (AND) между текущим значением и заданным, возвращая старое значение.
- Atomics.compareExchange() записывает значение в указанной ячейке, если текущее значение совпадает с указанным для сравнения. Возвращает предыдущее значение.
- Atomics.exchange() записывает новое значение в указанную ячейку массива и возвращает предыдущее значение.
- Atomics.isLockFree() проверяет, можно ли выполнить атомарную операцию без блокировок для массива данного типа. Возвращает true, если операция будет атомарной на уровне аппаратуры.
- Atomics.load() возвращает текущее значение в указанной ячейке массива.
- Atomics.notify() уведомляет потоки, ожидающие изменения в указанной ячейке массива. Возвращает количество уведомленных потоков.
- Atomics.or() выполняет побитовое ИЛИ (OR) между текущим значением и заданным, возвращая старое значение.
- Atomics.store() записывает значение в указанную ячейку и возвращает это значение.
- Atomics.sub() вычитает заданное значение из текущего значения в указанной ячейке и возвращает предыдущее значение.
- Atomics.wait() проверяет, что значение в указанной ячейке совпадает с заданным, и приостанавливает выполнение потока, пока значение не изменится или не истечет время ожидания. Возвращает "ok", "not-equal" или "timed-out". (Не поддерживается в главном потоке браузера.)
- Atomics.waitAsync() ожидает асинхронно, возвращая промис, что позволяет не блокировать поток, как в случае с Atomics.wait.
- Atomics.xor() выполняет побитовое XOR (исключающее ИЛИ) между текущим значением и заданным, возвращая старое значение.
Заключение
Работа с многопоточностью в Node.js требует осторожности, особенно когда несколько потоков используют общие ресурсы. На данный момент только механизм Atomics помогает эффективно справиться с этой задачей с помощью атомарных операций, которые исключают возникновение состояния гонки.
С какими сложностями в многопоточном программировании вы сталкивались, и как решали эти проблемы до знакомства с Atomics?
Комментарии