Нити. Основные примитивы синхронизации
В многопоточных приложениях несколько потоков (нитей) одновременно обращаются к разделяемым ресурсам (память, файлы, устройства). Без надлежащей синхронизации возникают состояния гонки (race condition
В многопоточных приложениях несколько потоков (нитей) одновременно обращаются к разделяемым ресурсам (память, файлы, устройства). Без надлежащей синхронизации возникают состояния гонки (race conditions), когда результат выполнения зависит от непредсказуемого порядка чередования операций. Синхронизация нитей – это дисциплина координации потоков, обеспечивающая корректный доступ к общим данным и упорядоченное выполнение критических секций. В этой статье рассматриваются основные примитивы синхронизации, предоставляемые современными операционными системами и библиотеками (POSIX threads), их свойства, области применения и практические примеры на языке C. Материал предназначен для разработчиков системного и прикладного программного обеспечения, знакомых с основами многопоточности.
1.1. Английские сокращения
| Сокращение | Английское обозначение | Русское обозначение |
| CS | Critical Section | Критическая секция |
| Mutex | Mutual Exclusion | Взаимное исключение |
| Sem | Semaphore | Семафор |
| CV | Condition Variable | Условная переменная |
| RWLock | Read-Write Lock | Блокировка чтения-записи |
| Spinlock | Spinlock | Спинлок (блокировка с активным ожиданием) |
| Monitor | Monitor | Монитор |
| FIFO | First In, First Out | Первым пришёл – первым обслужен |
| CAS | Compare-And-Swap | Сравнение с обменом |
| RCU | Read-Copy-Update | Чтение-копирование-обновление |
| OS | Operating System | Операционная система |
| API | Application Programming Interface | Интерфейс программирования приложений |
| TLS | Thread‑Local Storage | Локальное хранилище нити |
| FIFO | First In, First Out | Первым пришёл – первым обслужен |
| POSIX | Portable Operating System Interface | Переносимый интерфейс операционных систем |
| Pthread | POSIX Threads | Потоки POSIX |
1.2. Русские сокращения
| Сокращение | Обозначение |
| КС | Критическая секция |
| ВИ | Взаимное исключение |
| СМ | Семафор |
| УП | Условная переменная |
| БЗ | Блокировка чтения-записи |
| СБ | Спин-блокировка (спинлок) |
| МН | Монитор |
| ОС | Операционная система |
| ПН | Планирование нитей |
| СП | Синхронизация потоков |
2.1. Состояние гонки (Race Condition)
Состояние гонки возникает, когда несколько потоков одновременно обращаются к разделяемым данным, и итоговое значение зависит от относительного порядка выполнения операций. Например, два потока одновременно инкрементируют одну переменную: при отсутствии синхронизации возможна потеря обновления, если оба прочитают старое значение, увеличат его и запишут.
2.2. Критическая секция (Critical Section)
Критическая секция – это участок кода, который должен выполняться атомарно относительно других потоков. Внутри критической секции поток работает с разделяемыми данными. Чтобы гарантировать корректность, необходимо, чтобы в любой момент времени только один поток находился внутри критической секции.
2.3. Требования к синхронизации
- Взаимное исключение (Mutual Exclusion): только один поток может одновременно находиться в критической секции.
- Прогресс (Progress): если ни один поток не находится в критической секции и есть потоки, желающие войти, то один из них должен быть допущен.
- Ограниченное ожидание (Bounded Waiting): поток не должен ждать бесконечно долго для входа в критическую секцию.
2.4. Основные проблемы
- Взаимоблокировка (Deadlock): два или более потоков блокируют друг друга, удерживая ресурсы и ожидая освобождения других.
- Голодание (Starvation): поток не получает доступа к ресурсу из-за постоянного предпочтения других потоков.
- Livelock: потоки постоянно меняют состояние, но не могут продвинуться.
- Инверсия приоритетов (Priority Inversion): высокоприоритетный поток заблокирован низкоприоритетным, захватившим общий ресурс.
2.5. Исторический контекст и эволюция
Первые примитивы синхронизации (семафоры) были предложены Дейкстрой в 1960-х годах. С развитием многопоточных систем появились мьютексы, условные переменные, барьеры. В современных ОС и библиотеках (pthreads, WinAPI, C++11) реализованы все основные механизмы, обеспечивающие эффективную и безопасную синхронизацию.
3.1. Определение
Мьютекс (mutual exclusion) – это бинарный объект синхронизации, который может находиться в двух состояниях: заблокирован (locked) и свободен (unlocked). Только один поток может владеть мьютексом в любой момент времени. Владелец может освободить мьютекс, после чего другой поток может его захватить.
3.2. Операции
- `pthread<sub>mutex</sub>_lock(mutex)`: пытается захватить мьютекс. Если он занят, поток блокируется до освобождения.
- `pthread<sub>mutex</sub>_trylock(mutex)`: пытается захватить мьютекс без блокировки; возвращает ошибку, если занят.
- `pthread<sub>mutex</sub>_unlock(mutex)`: освобождает мьютекс (должен вызываться владельцем).
3.3. Типы мьютексов (POSIX)
- Обычный (fast): нерекурсивный, повторный захват тем же потоком приводит к deadlock.
- Рекурсивный: позволяет одному потоку захватывать мьютекс несколько раз, счётчик увеличивается; требуется столько же освобождений.
- Адаптивный: при попытке захвата занятого мьютекса сначала выполняется спин-ожидание, затем блокировка.
3.4. Пример: защита счётчика с помощью мьютекса (C, POSIX threads)
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int counter = 0;
void* increment(void* arg) {
for (int i = 0; i < 100000; i++) {
pthread_mutex_lock(&mutex);
counter++;
pthread_mutex_unlock(&mutex);
}
return NULL;
}
int main() {
pthread_t t1, t2;
pthread_create(&t1, NULL, increment, NULL);
pthread_create(&t2, NULL, increment, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("Counter = %d\n", counter); // должно быть 200000
pthread_mutex_destroy(&mutex);
return 0;
}
3.5. Достоинства и недостатки
- + Простота использования, надёжная защита критических секций.
- – Негибкость (только взаимное исключение), риск deadlock при неаккуратном использовании.
4.1. Определение
Семафор – это целочисленный счётчик, поддерживающий две атомарные операции: `sem<sub>wait</sub>()` (уменьшить) и `sem<sub>post</sub>()` (увеличить). Семафоры могут быть двоичными (значения 0 или 1) и счётчиковыми (значения > 1). Они используются для ограничения доступа к пулу ресурсов или для синхронизации событий.
4.2. Операции
- `sem<sub>wait</sub>(sem)`: если счётчик > 0, уменьшить его и продолжить; иначе заблокировать поток.
- `sem<sub>trywait</sub>(sem)`: неблокирующая версия.
- `sem<sub>post</sub>(sem)`: увеличить счётчик; если есть заблокированные потоки, разбудить один.
4.3. Пример: ограничение числа одновременных подключений к ресурсу (семафор с начальным значением 3)
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>
#include <unistd.h>
sem_t sem;
void* worker(void* arg) {
int id = *(int*)arg;
sem_wait(&sem);
printf("Поток %d: начал работу\n", id);
sleep(2);
printf("Поток %d: завершил работу\n", id);
sem_post(&sem);
return NULL;
}
int main() {
sem_init(&sem, 0, 3); // максимум 3 одновременно
pthread_t threads[5];
int ids[5] = {1,2,3,4,5};
for (int i=0; i<5; i++) {
pthread_create(&threads[i], NULL, worker, &ids[i]);
}
for (int i=0; i<5; i++) {
pthread_join(threads[i], NULL);
}
sem_destroy(&sem);
return 0;
}
4.4. Достоинства и недостатки
- + Гибкость (можно использовать как счётчик ресурсов).
- – Требует осторожности для предотвращения deadlock; семафоры сложнее в отладке, чем мьютексы.
5.1. Определение
Условная переменная используется вместе с мьютексом для организации ожидания определённого состояния. Поток, обнаружив, что условие не выполнено, освобождает мьютекс и блокируется на условной переменной. Другой поток, изменив состояние, сигнализирует условной переменной, пробуждая ожидающий поток, который затем повторно захватывает мьютекс и проверяет условие (часто в цикле).
5.2. Операции
- `pthread<sub>cond</sub>_wait(cond, mutex)`: атомарно освобождает мьютекс и блокируется на cond.
- `pthread<sub>cond</sub>_signal(cond)`: разбудить один ожидающий поток.
- `pthread<sub>cond</sub>_broadcast(cond)`: разбудить все ожидающие потоки.
5.3. Пример: Producer-Consumer с ограниченным буфером (C)
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#define BUFFER_SIZE 5
int buffer[BUFFER_SIZE];
int count = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
void* producer(void* arg) {
for (int i = 0; i < 10; i++) {
pthread_mutex_lock(&mutex);
while (count == BUFFER_SIZE) {
pthread_cond_wait(&cond, &mutex);
}
buffer[count++] = i;
printf("Produced: %d\n", i);
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
}
return NULL;
}
void* consumer(void* arg) {
for (int i = 0; i < 10; i++) {
pthread_mutex_lock(&mutex);
while (count == 0) {
pthread_cond_wait(&cond, &mutex);
}
int item = buffer[--count];
printf("Consumed: %d\n", item);
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
}
return NULL;
}
int main() {
pthread_t p, c;
pthread_create(&p, NULL, producer, NULL);
pthread_create(&c, NULL, consumer, NULL);
pthread_join(p, NULL);
pthread_join(c, NULL);
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);
return 0;
}
5.4. Достоинства и недостатки
- + Эффективное ожидание изменения состояния без активного опроса.
- – Требует аккуратной работы с мьютексом; возможны ложные пробуждения (spurious wakeups) – необходимо повторно проверять условие.
6.1. Определение
Барьер – это точка синхронизации, в которой группа потоков (заданного размера) останавливается и ждёт, пока все потоки не достигнут барьера. После этого все потоки продолжают выполнение одновременно. Используется в параллельных алгоритмах, где фазы вычислений должны синхронизироваться.
6.2. Операции
- `pthread<sub>barrier</sub>_wait(barrier)`: поток блокируется, пока число достигших барьера потоков не станет равным заданному количеству; когда это происходит, все потоки освобождаются.
6.3. Пример: синхронизация фаз вычислений
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#define NUM_THREADS 3
pthread_barrier_t barrier;
void* worker(void* arg) {
int id = *(int*)arg;
printf("Поток %d: фаза 1\n", id);
sleep(1);
pthread_barrier_wait(&barrier);
printf("Поток %d: фаза 2 (после барьера)\n", id);
return NULL;
}
int main() {
pthread_barrier_init(&barrier, NULL, NUM_THREADS);
pthread_t threads[NUM_THREADS];
int ids[NUM_THREADS] = {1,2,3};
for (int i=0; i<NUM_THREADS; i++) {
pthread_create(&threads[i], NULL, worker, &ids[i]);
}
for (int i=0; i<NUM_THREADS; i++) {
pthread_join(threads[i], NULL);
}
pthread_barrier_destroy(&barrier);
return 0;
}
6.4. Достоинства и недостатки
- + Удобен для согласования этапов параллельных вычислений.
- – Требует знания количества потоков заранее; негибкий.
7.1. Определение
Разделяет доступ: несколько потоков могут одновременно читать данные (разделяемая блокировка), но запись возможна только одним потоком и при отсутствии читателей (исключительная блокировка). Это повышает параллелизм при интенсивном чтении.
7.2. Операции
- `pthread<sub>rwlock</sub>_rdlock(rwlock)`: захват для чтения.
- `pthread<sub>rwlock</sub>_wrlock(rwlock)`: захват для записи.
- `pthread<sub>rwlock</sub>_unlock(rwlock)`: освобождение.
7.3. Пример: разделяемое чтение/запись
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
int shared_data = 0;
void* reader(void* arg) {
pthread_rwlock_rdlock(&rwlock);
printf("Reader: shared_data = %d\n", shared_data);
pthread_rwlock_unlock(&rwlock);
return NULL;
}
void* writer(void* arg) {
pthread_rwlock_wrlock(&rwlock);
shared_data++;
printf("Writer: incremented to %d\n", shared_data);
pthread_rwlock_unlock(&rwlock);
return NULL;
}
int main() {
pthread_t r1, r2, w1;
pthread_create(&r1, NULL, reader, NULL);
pthread_create(&r2, NULL, reader, NULL);
pthread_create(&w1, NULL, writer, NULL);
pthread_join(r1, NULL);
pthread_join(r2, NULL);
pthread_join(w1, NULL);
pthread_rwlock_destroy(&rwlock);
return 0;
}
7.4. Достоинства и недостатки
- + Повышает производительность при частых чтениях.
- – Усложняет логику; возможна инверсия приоритетов и голодание писателей.
8.1. Определение
Спинлок – это блокировка, при которой поток в цикле (busy‑wait) проверяет доступность, не переходя в режим ожидания (блокировки). Применяется в ядре ОС и в системах реального времени, где переключение контекста дороже цикла ожидания, а критическая секция очень коротка.
8.2. Операции
- `pthread<sub>spin</sub>_lock(spin)`: захват.
- `pthread<sub>spin</sub>_unlock(spin)`: освобождение.
8.3. Пример (C)
#include <stdio.h>
#include <pthread.h>
pthread_spinlock_t spin;
void* worker(void* arg) {
pthread_spin_lock(&spin);
// критическая секция (короткая)
printf("Поток %ld работает\n", (long)arg);
pthread_spin_unlock(&spin);
return NULL;
}
int main() {
pthread_spin_init(&spin, PTHREAD_PROCESS_PRIVATE);
pthread_t t1, t2;
pthread_create(&t1, NULL, worker, (void*)1);
pthread_create(&t2, NULL, worker, (void*)2);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_spin_destroy(&spin);
return 0;
}
8.4. Достоинства и недостатки
- + Низкая задержка (нет переключения контекста).
- – Загружает процессор, подходит только для очень коротких критических секций.
9.1. Определение
Монитор – это конструкция языка программирования (или паттерн), объединяющая данные, процедуры и примитивы синхронизации. Доступ к данным внутри монитора разрешён только через его методы, которые автоматически синхронизируются (взаимное исключение). Мониторы также поддерживают условные переменные. Реализованы в Java (synchronized), C# (lock), Python (с использованием threading.Condition). В чистом C мониторы не являются встроенной конструкцией, но могут быть эмулированы с помощью мьютексов и условных переменных.
9.2. Пример эмуляции монитора в C (мьютекс + функции)
#include <pthread.h>
typedef struct {
int value;
pthread_mutex_t mutex;
} Monitor;
void monitor_init(Monitor *m) {
m->value = 0;
pthread_mutex_init(&m->mutex, NULL);
}
void monitor_inc(Monitor *m) {
pthread_mutex_lock(&m->mutex);
m->value++;
pthread_mutex_unlock(&m->mutex);
}
int monitor_get(Monitor *m) {
pthread_mutex_lock(&m->mutex);
int v = m->value;
pthread_mutex_unlock(&m->mutex);
return v;
}
9.3. Достоинства и недостатки
- + Инкапсуляция, упрощает управление синхронизацией.
- – В C требует явного вызова функций; может снижать производительность при избыточном использовании.
| Примитив | Назначение | Блокирование | Типичное применение |
| Мьютекс | Взаимное исключение | Да | Защита критических секций |
| Семафор | Ограничение доступа к ресурсу (счётчик) | Да | Пулы соединений, ограничение параллелизма |
| Условная переменная | Ожидание сигнала об изменении условия | Да (с мьютексом) | Producer-Consumer, задачи с зависимостями |
| Барьер | Синхронизация фаз | Да | Параллельные алгоритмы с этапами |
| RWLock | Разделение чтения/записи | Да | Частое чтение, редкая запись |
| Спинлок | Блокировка с активным ожиданием | Нет (цикл) | Очень короткие секции, ядро ОС |
| Монитор | Инкапсуляция синхронизации | Да | ООП-языки, поддержка условных переменных |
- Для защиты простых критических секций предпочтительнее мьютекс.
- Для ограничения параллелизма (например, число одновременных подключений) используйте семафор.
- Для задач с ожиданием изменения состояния – условную переменную.
- Для синхронизации фаз в циклах – барьер.
- Для данных с интенсивным чтением – RWLock.
- В высокопроизводительных ядерных модулях, где критическая секция занимает несколько инструкций, применяйте спинлок.
- В объектно-ориентированных языках используйте мониторы (synchronized, lock) для инкапсуляции.
12.1. Взаимоблокировка (Deadlock)
Условия возникновения: взаимное исключение, удержание и ожидание, отсутствие вытеснения, циклическое ожидание. Стратегии предотвращения:
- Упорядочивание ресурсов.
- Использование таймаутов (pthread<sub>mutex</sub>_timedlock).
- Применение иерархии блокировок.
12.2. Голодание (Starvation)
Может быть вызвано приоритетным планированием. Для предотвращения – использовать справедливые очереди (FIFO) или повышать приоритет ожидающих потоков.
12.3. Инверсия приоритетов
Низкоприоритетный поток удерживает ресурс, необходимый высокоприоритетному. В POSIX можно использовать протокол наследования приоритетов (`pthread<sub>mutexattr</sub>_setprotocol`) или протокол потолка приоритетов.
12.4. Ложные пробуждения (Spurious wakeups)
Условные переменные могут пробуждаться без сигнала. Всегда проверяйте условие в цикле после выхода из `pthread<sub>cond</sub>_wait`.
- Напишите программу на C с использованием мьютекса, моделирующую банковский перевод между двумя счетами с защитой от гонок.
- Реализуйте задачу «Reader-Writers» с приоритетом писателей на основе условных переменных (или RWLock).
- Используйте барьер для синхронизации трёх потоков, которые сначала генерируют данные, затем обрабатывают, затем сохраняют результат. Предусмотрите повторение циклов.
- Сравните время выполнения инкремента с мьютексом и со спинлоком для 1000000 операций (подсказка: используйте `clock<sub>gettime</sub>`).
- Спроектируйте систему ограничения параллельных запросов к сервису (макс. 5) с использованием семафора.
Синхронизация нитей является краеугольным камнем многопоточного программирования. Правильный выбор примитива (мьютекс, семафор, условная переменная, барьер, RWLock, спинлок, монитор) зависит от конкретной задачи: необходимо учитывать требуемый уровень параллелизма, время выполнения критической секции, допустимые накладные расходы на переключение контекста. Понимание свойств каждого механизма позволяет разработчику создавать надёжные и производительные параллельные системы, избегая типичных ошибок (deadlock, starvation, инверсия приоритетов).
| Примитив | Тип | Инициализация | Блокировка | Разблокировка | Уничтожение |
| Мьютекс | pthread<sub>mutex</sub>_t | pthread<sub>mutex</sub>_init() | pthread<sub>mutex</sub>_lock() | pthread<sub>mutex</sub>_unlock() | pthread<sub>mutex</sub>_destroy() |
| Семафор | sem<sub>t</sub> | sem<sub>init</sub>() | sem<sub>wait</sub>() | sem<sub>post</sub>() | sem<sub>destroy</sub>() |
| Условная переменная | pthread<sub>cond</sub>_t | pthread<sub>cond</sub>_init() | pthread<sub>cond</sub>_wait() | pthread<sub>cond</sub>_signal() / broadcast() | pthread<sub>cond</sub>_destroy() |
| Барьер | pthread<sub>barrier</sub>_t | pthread<sub>barrier</sub>_init() | pthread<sub>barrier</sub>_wait() | (автоматически после барьера) | pthread<sub>barrier</sub>_destroy() |
| RWLock | pthread<sub>rwlock</sub>_t | pthread<sub>rwlock</sub>_init() | pthread<sub>rwlock</sub>_rdlock() / wrlock() | pthread<sub>rwlock</sub>_unlock() | pthread<sub>rwlock</sub>_destroy() |
| Спинлок | pthread<sub>spinlock</sub>_t | pthread<sub>spin</sub>_init() | pthread<sub>spin</sub>_lock() | pthread<sub>spin</sub>_unlock() | pthread<sub>spin</sub>_destroy() |
Все примеры на C компилируются с флагом `-pthread` и требуют стандартной библиотеки POSIX. Для выполнения в среде Linux используйте GCC. В целях обучения примеры могут быть запущены на любой Unix-подобной системе. Для получения более детальной информации обращайтесь к документации (man pthreads).