Блог "Дети ОИТа"
/поиск

Нити. Основные примитивы синхронизации

В многопоточных приложениях несколько потоков (нитей) одновременно обращаются к разделяемым ресурсам (память, файлы, устройства). Без надлежащей синхронизации возникают состояния гонки (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`.

  1. Напишите программу на C с использованием мьютекса, моделирующую банковский перевод между двумя счетами с защитой от гонок.
  2. Реализуйте задачу «Reader-Writers» с приоритетом писателей на основе условных переменных (или RWLock).
  3. Используйте барьер для синхронизации трёх потоков, которые сначала генерируют данные, затем обрабатывают, затем сохраняют результат. Предусмотрите повторение циклов.
  4. Сравните время выполнения инкремента с мьютексом и со спинлоком для 1000000 операций (подсказка: используйте `clock<sub>gettime</sub>`).
  5. Спроектируйте систему ограничения параллельных запросов к сервису (макс. 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).