Потоки в C++. Приложение «Сторожевой пес». Простой подход.

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

Прежде чем мы начнем, я дам несколько упрощенных сведений о потоках в компьютерных науках.
Операционная система (ОС) отвечает за управление аппаратным обеспечением от имени приложения, которое вы запускаете. Приложение, которое вы разработали и скомпилировали, хранится на диске (статический объект). Когда вы даете команду запустить приложение, код «переносится» в оперативную память и может быть связан как активный объект (ПРОЦЕСС). Вы можете запускать несколько процессов (одинаковых или разных) на одном процессоре. Каждое из этих приложений может находиться в различном состоянии, например, выполняться или ожидать, но все же как процессы.

Каждый процесс может быть представлен в виде кода, данных (статические данные, загружаемые при запуске процесса), кучи (динамически выделяемой памяти) и стека (памяти, которая увеличивается и сокращается во время выполнения программы — она организована в порядке LIFO— List In Fist Out).
Каждый процесс однозначно идентифицируется адресом в адресном пространстве (V0 — Vmax). Есть виртуальные адреса. Из-за сложности управления виртуальной памятью (запускаем разные приложения, используем разные компиляторы и т.д.) прямой связи с памятью физических адресов (DRAM) нет. ОС должна сопоставлять виртуальные адреса с физической памятью. Это делается с помощью таблиц страниц. Таким образом, одна переменная с виртуальным адресом, равным 0x56543, должна быть отображена таблицей страниц, например, на 0x9864728 в DRAM.

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

Вернемся к сути нашего обсуждения и рассмотрим следующее многопоточное приложение, которое поможет вам лучше понять этот период программирования на C++.
Наше приложение очень простое. Идея заключается в следующем (пожалуйста, при чтении обратите внимание также на рисунок ниже, на котором изображена основная аннотация рассматриваемого приложения).
Приложение выполняет 4 потока: основной, где определены три других потока: threadOne, threadTwo и threadTree. .

std::thread your_thread (functionToBeCalled); 
// spawn new thread that calls functionToBeCalled()

Основной поток имеет наивысший приоритет, и когда он завершен, приложение завершается.
Чтобы поддерживать другие потоки (которые мы определили ранее), нам нужно отправить указание основному потоку, что он должен ждать, пока другие потоки завершат оставшийся набор инструкций. Это происходит с помощью метода join().

your_thread.join();

В нашем случае потоки: threadOne, threadTwo и threadTree выполняются в «долгое время» для циклических функций threadOneWorker, threadTwoWorker и watchDog соответственно. Все приложение запускается, пока оно будет убито.

Что касается приведенного ниже рисунка, мы видим, что все четыре потока работают.
Вы можете проверить это в htop и выполнить поиск команды watchdog.

Как мы упоминали ранее, потоки работают независимо, однако они могут совместно использовать одну и ту же память.
В нашем приложении мы можем сказать, что потоки threadOne и threadTwo что-то делают в нашей системе (один поток отвечает, например, за чтение), а другой поток выполняет набор инструкций, отвечающих за отправку. Последний поток: threadThree запускает функцию watchDog, которая подсчитывает время (в данном примере 5*200 миллисекунд). Это временное пространство, в котором мы можем запускать приложение без Алерта — мы можем сказать «сторожевой таймер лает». Когда на этот раз время истечет, будет поднято предупреждение.
Вы можете представить себе функцию watchDog. Подсчитывая время (конец, ожидая, что счетчик времени будет сброшен), watchDog контролирует threadOne и threadTwo и вызывает тревогу, когда время для сброса истекло (это только простой пример, показывающий, как может быть организован такой сценарий)

В течение этого промежутка времени (5 * 200 миллисекунд) время должно быть установлено на 0. Это делается функциями threadOneWorker или threadTwoWorker. Пожалуйста, обратите внимание, что мы вызываем эти функции с разным временем сна. threadOneWorker с 1900, threadTwoWorker с 750.
Потоки: threadOne, threadTwo и threadTree совместно используют одну и ту же память: shearedTimer, которая содержит фактический счетчик (количество пройденных раз 200 миллисекунд).
Доступ к указанной общей памяти разрешен. управляемый (защищенный) мьютексом. Это специальный механизм, защищающий от «гонки нитей». Это означает, что доступ к памяти (в нашем случае shearedTimer) разрешен только одному потоку одновременно. Мьютекс может быть связан как «дверь» в память. Дверь (мьютекс) можно закрыть. Поток вызывает метод lock() и открывает дверь (дает возможность доступа к памяти другим потокам), вызывая метод unlock().
Почувствуйте интуицию, взглянув на следующий псевдокод. Вы также можете уловить интуицию, непосредственно проанализировав наш пример приложения.

/pseudo-code
shared_memory;
mutex; //controls access to share_memory
def your_thread_1(funct_1); 
//definition of thread which calls function funct_1
def your_thread_2(funct_2);
//definition of thread which calls function funct_2
//your_thread_1 and your_thread_2 runs independently and fights for access to share_memory
// the share_memory is available for one that at the time.
// the control for the availability is performed by mutex
funct_1 () {
mutex.lock();
call (sheared_memory) // your_thread_2 DO NOT have an access to //memory
mutex.unlock ()
}
funct_2 () {
mutex.lock();
call (sheared_memory) // your_thread_1 DO NOT have an access to //shared_memory
mutex.unlock ()
}

Когда поток lock() обращается к памяти (имеет доступ к общей памяти), поток может изменить значение памяти. Остальные потоки должны ждать разблокировки мьютекса.

Скомпилируйте свою программу как (в Linux):

g++ watchdog_medium.cpp -o watchdog -pthread

и запустите:

./watchdog

В следующей программе у нас есть threadOneWorker(), который пытается сбросить время каждые 1900 миллисекунд — слишком долго, чтобы предупреждение не поднималось (если мы отключим threadTwo, я имею в виду, что мы запускаем только threadOne ) и threadTwoWorker(), который сбрасывает таймер каждые 750 миллисекунд (за 250 миллисекунд до появления предупреждения).

Пожалуйста, вдохновляйтесь и играйте с программой. Настройте время ожидания для каждой функции потока. Проверьте последствия ваших изменений.

Спасибо за чтение.