Раздел 16 — Потоки

Как одна программа делает несколько дел одновременно — и почему это опасно

Прогресс курса Раздел 16 из 20

Что вы освоите в этом разделе

8 академических часов теории и 4 часа практики: практическая работа №17 «Использование потоков» — на портале. Программы раздела работают в Windows; сборка — обычной командой, библиотеки потоков MinGW-w64 подключает сам.
01

Поток и процесс

В разделе 14 процесс был контейнером: адресное пространство, дескрипторы, код завершения. Выполняет код не процесс, а поток — последовательность команд со своим местом выполнения и своим стеком (раздел 7, пункт 05). При запуске программы система создаёт процесс и в нём один поток — он выполняет main. Программа может создать ещё потоки: тогда в одном процессе одновременно выполняются несколько функций.

У каждого потока своёОбщее для потоков процесса
место выполнения (какая команда сейчас)адресное пространство: код, глобальные данные, кучи
стек: локальные переменные и параметры функцийдескрипторы: файлы, консоль, каналы
идентификатор, приоритет, код последней ошибкитекущий каталог, командная строка

Зачем потоки. Первое — параллельность: в процессоре несколько ядер, и однопоточная программа занимает одно из них; разделив вычисление на потоки, его можно ускорить в разы (пункт 08). Второе — отзывчивость: пока один поток ждёт — сети, диска, клавиши, — другой работает. Консольный чат в итоговом проекте принимает сообщения в одном потоке, пока другой ждёт ввода пользователя.

Как это работает. Планировщик ОС раздаёт потокам ядра процессора квантами — по нескольку миллисекунд — и переключает их. Потоков обычно больше, чем ядер, поэтому одни выполняются, а другие ждут очереди. Какой поток когда получит ядро, программа не знает: порядок меняется от запуска к запуску. На этом держится всё, что есть опасного в потоках (пункт 09).

02

Создание потока: CreateThread

Поток выполняет функцию потока — обычную функцию с заданным заголовком: DWORD WINAPI имя(LPVOID param). Параметр — адрес любых данных, результат — код завершения потока. WINAPI — соглашение о вызове функций Windows.

// Три потока: параметры — через указатель на структуру, ожидание всех, коды завершения
#include <windows.h>
#include <iostream>

constexpr int THREADS = 3;

struct Job {
    int number;                                 // номер задания — для вывода
    int repeats;                                // сколько строк вывести
    DWORD pauseMs;                              // пауза между строками
};

DWORD WINAPI worker(LPVOID param);

int main()
{
    Job jobs[THREADS] = {                       // живут до конца main — дольше потоков
        {1, 3, 100},
        {2, 3, 150},
        {3, 3, 70}
    };
    HANDLE threads[THREADS];
    DWORD ids[THREADS];
    std::cout << "main thread " << GetCurrentThreadId() << '\n';
    for (int i = 0; i < THREADS; ++i) {
        threads[i] = CreateThread(nullptr, 0, worker, &jobs[i], 0, &ids[i]);
        if (threads[i] == nullptr) {
            std::cout << "CreateThread failed, error " << GetLastError() << '\n';
            return 2;
        }
        std::cout << "started job " << jobs[i].number << " in thread " << ids[i] << '\n';
    }

    WaitForMultipleObjects(THREADS, threads, TRUE, INFINITE);   // TRUE — ждать все
    for (int i = 0; i < THREADS; ++i) {
        DWORD code = 0;
        GetExitCodeThread(threads[i], &code);
        std::cout << "thread " << ids[i] << " exit code " << code << '\n';
        CloseHandle(threads[i]);
    }
    return 0;
}

// Функция потока: параметр — адрес Job; результат — код завершения потока
DWORD WINAPI worker(LPVOID param)
{
    const Job* job = static_cast<const Job*>(param);
    for (int k = 1; k <= job->repeats; ++k) {
        std::cout << "job " << job->number << ": step " << k << '\n';
        Sleep(job->pauseMs);
    }
    return static_cast<DWORD>(job->number * 10);
}
main thread 11240
started job 1 in thread 9808
job 1: step 1
started job 2 in thread 3316
job 2: step 1
started job 3 in thread 12756
job 3: step 1
job 3: step 2
job 1: step 2
job 3: step 3
job 2: step 2
job 1: step 3
job 2: step 3
thread 9808 exit code 10
thread 3316 exit code 20
thread 12756 exit code 30
Параметр CreateThreadВ курсеСмысл
атрибуты безопасностиnullptrпо умолчанию
размер стека0по умолчанию — 1 МБ
функция потокаимя функциичто выполнять; имя функции без скобок — её адрес
параметрадрес структурыпридёт в функцию как LPVOID param
флаги0 или CREATE_SUSPENDEDзапустить сразу или приостановленным (пункт 07)
адрес идентификатора&ids[i] или nullptrсюда запишется идентификатор потока

CreateThread возвращает дескриптор потока (NULL — ошибка) сразу после создания — не дожидаясь, пока поток что-то сделает. Порядок строк вывода у вас будет другим и будет меняться от запуска к запуску: потоки работают одновременно. Иногда строки даже перемешаются посимвольно — два потока пишут в std::cout одновременно; это первая встреча с гонкой (пункт 09).

03

Данные для потока

У функции потока один параметр LPVOID — «адрес чего угодно» (void*). Всё, что нужно потоку, собирают в структуру (раздел 11), её адрес передают в CreateThread, а в функции потока приводят обратно: static_cast<Job*>(param). Через ту же структуру поток отдаёт результат — записывает его в поле, а main читает после ожидания.

Данные должны жить дольше потока. Поток получает адрес, а не копию. Если передать адрес локальной переменной функции, которая завершится раньше потока, поток будет читать «висячий» адрес (раздел 9, пункт 03). Ещё одна классическая ошибка — один адрес на все потоки:
for (int i = 0; i < 3; ++i) {
    threads[i] = CreateThread(nullptr, 0, worker, &i, 0, nullptr);   // ОШИБКА: у всех — адрес одной i
}
Потоки читают i, когда им достанется процессор, — к этому времени цикл уже её изменил: несколько потоков получат одинаковый номер, а после цикла i вообще нет. Правило: у каждого потока — свой элемент массива структур, и массив живёт до конца ожидания.
04

Завершение потока и код завершения

05

Идентификатор и дескриптор

Идентификатор (ID)Дескриптор (HANDLE)
Что эточисло, уникальное среди живых потоков всей системыномер в таблице своего процесса
Где взятьпоследний параметр CreateThread; свой — GetCurrentThreadId()результат CreateThread; свой — GetCurrentThread() (псевдодескриптор)
Для чегоотличать потоки в журнале и выводе; Диспетчер задач, отладчикждать, узнавать код, менять приоритет, приостанавливать
Закрыватьнетда, CloseHandle (кроме псевдодескриптора)

Как с процессами: идентификатор — «имя» для людей и других программ, дескриптор — «ключ» для работы с объектом. По идентификатору дескриптор получают OpenThread.

06

Приоритеты и Sleep

Планировщик сначала раздаёт ядра потокам с большим приоритетом. Приоритет потока складывается из класса приоритета процесса (раздел 14, GetPriorityClass) и относительного приоритета потока: SetThreadPriority(h, THREAD_PRIORITY_BELOW_NORMAL); значения — от THREAD_PRIORITY_IDLE до THREAD_PRIORITY_TIME_CRITICAL, обычный — THREAD_PRIORITY_NORMAL (0). Понижают приоритет фоновой работе, чтобы она не мешала интерфейсу; повышать без крайней нужды не стоит: поток с высоким приоритетом, который считает без остановки, может «подвесить» остальную систему.

Sleep(мс) отдаёт процессор: поток не выполняется заданное время, ядро получают другие. Sleep(0) — уступить остаток кванта, если кто-то ждёт. Время Sleep — не меньше заданного, но может быть больше: точность — около 15 мс, если программа не просила большей.

07

Ожидание, приостановка, возобновление

Поток, как процесс, переходит в сигнальное состояние, когда завершается, — значит, его ждут той же WaitForSingleObject (раздел 14, пункт 07). Несколько потоков сразу — WaitForMultipleObjects(n, массив, ждатьВсе, тайм-аут):

ВызовВернётся, когдаРезультат
WaitForMultipleObjects(n, h, TRUE, INFINITE)завершатся всеWAIT_OBJECT_0
WaitForMultipleObjects(n, h, FALSE, INFINITE)завершится любойWAIT_OBJECT_0 + i — номер завершившегося в массиве
с тайм-аутом в миллисекундах… или истечёт времяWAIT_TIMEOUT

Больше 64 объектов (MAXIMUM_WAIT_OBJECTS) одним вызовом не ждут. Пример control.cpp создаёт поток приостановленным, понижает приоритет, запускает и раз в секунду проверяет, не закончил ли он:

// Фрагмент control.cpp. Полностью — в примерах раздела
    DWORD seconds = 3;
    HANDLE thread = CreateThread(nullptr, 0, slowWorker, &seconds, CREATE_SUSPENDED, nullptr);
    if (thread == nullptr) {
        std::cout << "CreateThread failed, error " << GetLastError() << '\n';
        return 2;
    }
    SetThreadPriority(thread, THREAD_PRIORITY_BELOW_NORMAL);
    std::cout << "created suspended, priority " << GetThreadPriority(thread) << '\n';
    ResumeThread(thread);                       // только теперь поток начинает выполняться

    DWORD code = 0;
    while (WaitForSingleObject(thread, 1000) == WAIT_TIMEOUT) {
        GetExitCodeThread(thread, &code);
        std::cout << "still working, code " << code << (code == STILL_ACTIVE ? " (STILL_ACTIVE)" : "") << '\n';
    }
    GetExitCodeThread(thread, &code);
    std::cout << "finished, code " << code << '\n';
    CloseHandle(thread);
created suspended, priority -1
still working, code 259 (STILL_ACTIVE)
still working, code 259 (STILL_ACTIVE)
still working, code 259 (STILL_ACTIVE)
finished, code 7

SuspendThread(h) останавливает поток в любом месте, ResumeThread(h) — продолжает; у потока есть счётчик приостановок, и выполняется он, только когда счётчик 0. Приостанавливать работающий поток опасно: он может остановиться, держа критическую секцию или блокировку кучи, — и тогда встанут другие потоки. Безопасный случай — поток, созданный с CREATE_SUSPENDED: его настраивают до первого запуска.

08

Деление работы между потоками

Посчитать простые числа до 107 — несколько секунд на одном ядре. Разделим отрезок [2; 107] на T частей, и каждую отдадим своему потоку. Главное правило: каждый поток пишет только в свои данные — своё поле count в своей структуре. Тогда потоки друг другу не мешают, и синхронизация не нужна; main складывает результаты после ожидания.

// Фрагмент primes.cpp: отрезки, потоки, ожидание, сумма результатов. Полностью — в примерах раздела
struct Part {
    long long from;                             // отрезок [from; to]
    long long to;
    long long count;                            // результат потока — только его поле
};

    Part parts[MAX_THREADS];
    HANDLE threads[MAX_THREADS];
    const long long size = LIMIT - 1;           // чисел в [2; LIMIT]
    const ULONGLONG start = GetTickCount64();
    for (int i = 0; i < threadCount; ++i) {
        parts[i].from = 2 + size * i / threadCount;
        parts[i].to = 2 + size * (i + 1) / threadCount - 1;
        parts[i].count = 0;
        threads[i] = CreateThread(nullptr, 0, countPrimes, &parts[i], 0, nullptr);
        // проверка на nullptr — как в пункте 02
    }
    WaitForMultipleObjects(static_cast<DWORD>(threadCount), threads, TRUE, INFINITE);

    long long total = 0;
    for (int i = 0; i < threadCount; ++i) {
        total += parts[i].count;
        CloseHandle(threads[i]);
    }

// Функция потока: считает простые своего отрезка и пишет в своё поле count
DWORD WINAPI countPrimes(LPVOID param)
{
    Part* part = static_cast<Part*>(param);
    long long count = 0;
    for (long long n = part->from; n <= part->to; ++n) {
        if (isPrime(n)) {
            ++count;
        }
    }
    part->count = count;
    return 0;
}
ПотоковВремя (замер: 4 логических ядра)Ускорение
119 200 мс1
212 000 мс1,6
46 560 мс2,9
85 530 мс3,5
165 380 мс3,6

Результат — 664 579 при любом числе потоков. Время — замер на машине с 4 логическими ядрами, у вас будут свои числа. Почему ускорение меньше числа потоков:

Число ядер программа узнаёт GetSystemInfo(&info), поле dwNumberOfProcessors.

09

Гонка данных

Что будет, если потоки пишут в общую переменную? Четыре потока по миллиону раз увеличивают один счётчик. Ожидаем 4 000 000:

// Фрагмент race.cpp: у всех потоков — адрес одной структуры Shared. Полностью — в примерах раздела
DWORD WINAPI plainWorker(LPVOID param)
{
    Shared* shared = static_cast<Shared*>(param);
    for (int k = 0; k < STEPS; ++k) {
        ++shared->plain;                        // прочитать, прибавить 1, записать
    }
    return 0;
}
expected 4000000
plain    1382745, 31 ms
atomic   4000000, 78 ms
guarded  4000000, 265 ms

Числа — пример: у вас будут другие, но картина та же. Без защиты — меньше, и каждый раз по-разному. ++x — не одна операция, а три: прочитать значение из памяти в регистр процессора, прибавить 1, записать обратно. Если два потока на двух ядрах прочитали одно и то же 100, оба запишут 101 — одно увеличение потеряно:

ВремяПоток AПоток BВ памяти
1прочитал 100100
2прочитал 100100
3записал 101101
4записал 101101 — а должно быть 102

Это гонка данных: результат зависит от того, как планировщик чередует потоки. Она опасна тем, что проявляется не всегда: на одном ядре, на маленьких числах, под отладчиком программа может «работать». Условие гонки: несколько потоков обращаются к одним данным, хотя бы один пишет, и доступ ничем не упорядочен. В race.cpp счётчик объявлен volatile — иначе оптимизатор заменит миллион увеличений одним сложением, и гонка спрячется.

10

Синхронизация: атомарные операции и критическая секция

// Увеличение одной неделимой командой процессора
    InterlockedIncrement(&shared->atomic);          // поле типа volatile LONG

// Увеличение внутри критической секции: в ней одновременно только один поток
    EnterCriticalSection(&shared->lock);
    ++shared->guarded;
    LeaveCriticalSection(&shared->lock);
СредствоКак работаетКогда
Interlocked-функции: InterlockedIncrement, InterlockedDecrement, InterlockedAdd, InterlockedExchange, версии …64 для 64 битчтение, изменение и запись — одна неделимая (атомарная) команда процессораодна переменная-число: счётчик, флаг
Критическая секция CRITICAL_SECTIONучасток кода между EnterCriticalSection и LeaveCriticalSection выполняет только один поток; остальные ждут у входанесколько действий, которые должны выполниться вместе: изменить две переменные, вывести строку, добавить элемент в массив
11

Ловушки раздела

ОшибкаПризнакСообщение g++Как избежать
функция потока другого типане собираетсяinvalid conversion from 'int (*)(void*)' to 'LPTHREAD_START_ROUTINE'DWORD WINAPI f(LPVOID param)
адрес переменной цикла &i всем потокамодинаковые номера у разных потоковнетмассив структур, у потока свой элемент
данные потока — локальные переменные функции, которая завершилась раньшемусор, аварийное завершениенетданные живут до конца ожидания
main не ждёт потокиработа не доделана, вывод оборваннетWaitForMultipleObjects(…, TRUE, INFINITE)
результат прочитан до ожиданиянули или неполные значениянетчитать поля после ожидания
общий счётчик без защитырезультат меньше и разный от запуска к запускунетсвои поля у потоков; иначе Interlocked… или критическая секция
Enter без Leaveпрограмма «зависает»нетодин выход из секции; проверить все return
секции в разном порядкезависание иногданетединый порядок входа
дескрипторы потоков не закрытыутечка дескрипторовнетCloseHandle после ожидания
«ускорение» проверено одним запускомслучайные выводынетнесколько замеров, результат одинаков при любом T

Ключевые выводы раздела

Запомните главное

  • Поток выполняет код; потоки процесса делят память и дескрипторы, у каждого свой стек
  • CreateThread + функция DWORD WINAPI f(LPVOID); данные — адрес структуры, своей у каждого потока, живущей до конца ожидания
  • Код завершения — return функции потока; GetExitCodeThread после ожидания; дескриптор закрывают
  • Ожидание — WaitForSingleObject, WaitForMultipleObjects (все или любой, тайм-аут)
  • Деление работы: каждый поток пишет только свои данные, main собирает результат после ожидания
  • Гонка данных: общие данные, хотя бы одна запись, нет упорядочения — результат случаен
  • Interlocked… — для одной переменной, критическая секция — для группы действий; единый порядок входа
Связи раздела. Опирается на раздел 7 — стек вызовов и время жизни переменных; раздел 9 — указатели и void*; раздел 11 — структуры и стрелка; раздел 14 — дескрипторы, ожидание, коды завершения; раздел 15 — анимация и опрос клавиш. Нужен для: раздела 17 — поток читает канал, пока главный пишет; раздела 19 — сервер принимает клиентов в отдельных потоках; итогового проекта — чат и «Жизнь» с потоками.
Практическая работа №17 — на учебном портале. Задания по вариантам, критерии оценивания и сдача — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Раздел 15: Буфер экрана консоли Практика на портале