Еще одним полезным навыком, который вы можете получить в рамках данного курса, является умения работать с процессами.
После того, как вы написали ваш код, скомпилировали его и слинковали, наступает следующий важный этап - запуск. На этом этапе, как вы уже знаете, вначале происходит поиск динамических библиотек, а потом система начинает исполнять ваши инструкции. Обычно по жизни такого описания достаточно, но иногда нужно больше - например, если вы хотите распараллелить вашу программу.
Для этих целей есть специализированные библиотеки (OpenMP, MPI, OpenCL), а если лезть к видеокартам (что пока рановато), то и еще несколько. В рамках этой лабы попробуем сделать примерно то же самое базовыми низкоуровневыми инструментами, чтобы понимать основные механики без тяжеловесного синтаксиса или аккуратного полиморфизма, который скрывает интересные детали.
Жонглируем процессами.
Возможно, что-то подобное вы уже делали, но давайте еще раз аккуратно.
Возьмите файл 00_waiter.cpp
или напишите свой, который делает аналогичную вещь - бесконечно ждёт ввода пользователя.
(Так мы имитируем зависший или просто очень долгий процесс, чтобы успеть рассмотреть его, пока он не завершился.)
Скомпилируйте и запустите.
Этот процесс занимает тот терминал, из которого вы его запустили. Мы можете прервать его через Ctrl+C или "свернуть" в фон через Сtrl+Z.
К работе с фоном мы вернемся немного позже, а сейчас нам нужно оставить процесс висящим и посмотреть на него через "диспетчер задач". Для этого вам нужно либо запустить еще один терминал (это будет еще один экземпляр bash, даже если у вас виндоубунта), либо прервать первый процесс и запустить еще один вот так:
./a.out &
В качестве диспетчера задач удобно использовать top (консольный, черно-белый, обычно установлен по умолчанию) или htop (нужно ставить как отдельный пакет, зато красивый и удобный, тоже консольный). Запустите его и найдите там ваш процесс. В самом левом столбце будет его номер в системе (PID, process identificator).
Если вы запустите ваш бинарник несколько раз, у вас появится несколько процессов с одним названием и разными номерами. Обратите внимание, что если вы "прячете" процесс при помощи &, он сразу пишет вам его PID. Это независимые процессы с независимыми адресными пространствами, друг о друге они не знают. В других столбцах вы видите прочую информацию о процессах - сколько памяти они занимают, сколько процессорного времени, сколько времени запущены и т.д.
Из htop вы можете довольно удобно управлять процессами - например, передавать им разные сигналы. Выбрав конкретный процесс стрелками на клавиатуре, нажмите F9, и слева появится список возможных сигналов. Они стандартные, можете про них (при желании) почитать отдельно. Чаще всего вас будет интересовать SIGKILL, который предлагается по умолчанию - так можно завершить процесс.
Поэкспериментируйте с этой механикой, создавая и удаляя процессы.
Если кто-то делал это в нулевой лабе, лучше сделайте еще раз и освежите в памяти.
Давайте посмотрим, что произошло, если вы применили Ctrl+Z к зависшему процессу. На самом деле, он не исчез. Он встал на паузу и ушел в фоновый режим. Вы можете найти его в диспетчере задач, он значится в списке, но не ест процессорное время. Удалить его тоже можно.
Создайте бинарник, который не просто ждет ввода пользователя, а именно зависает, тратя ресурсы процессора - например, бесконечно бегая по циклу и перебирая какой-нибудь массив. Запустите его, посмотрите на него через htop. Можете также сделать бинарник, который тратит память (берет в куче кусочки и не возвращает их на место), а потом посмотреть и на него. А теперь спрячьте его в фон через Ctrl+Z и снова посмотрите на диспетчер задач.
Вернуть процесс обратно (и снять с паузы) можно с помощью команды fg – пишете fg и часть названия процесса, он умный, сам найдет нужный (например, fg ./1 или fg a.). Полный список того, что есть в фоне, можно посмотреть командой jobs. Если там несколько с одинаковым названием, обращаться к ним можно по номерам.
Если вы хотите, чтобы процесс висел в фоне, но при этом работал, его можно снять с паузы командой bg. Так же пишете bg ./1, он остается в фоне, его можно смотреть с помощью команды jobs, но в htop вы увидите, что он работает и ест процессор.
Поэкспериментируйте и с этой механикой. На данном примере вы отличий не увидите. Напишите свой пример -- какой-нибудь бесконечный цикл, например (желательно ещё не переполнить стек). Посмотрите, как эта механика соотносится с & из предыдущего пункта. Она несложная, но может оказаться полезной.
ВНИМАНИЕ В реальных проектах такая синхронизация не используется. Она ненадежна и опасна для данных, с которыми вы работаете. Но для того, чтобы получше познакомиться с процессами, это самый простой и знакомый вам инструмент.
Почитайте код в 01_waiter_and_file_holder.cpp
.
Он умеет открывать файл, держать его открытым, пока ждёт ввода пользователя, контрольно выводить этот ввод в файл и выходить.
Скомпилируйте этот код и запустите получившийся бинарник.
Спрячьте его в фон, пока он ждем ввода.
Убедитесь, что процесс появился в диспетчере задач, а в файле 1.txt
появилось правильное число.
Вытащите процесс из фона, введите ему число и снова проверьте тот же файл.
Теперь запустите несколько процессов, убирая их в фон, пока они ждут ввода. По очереди вытаскивая их из фона, вводите разные числа. Проверьте файл - в правильном ли порядке идут числа.
Скорее всего (спойлер), в правильном. В данном случае вы руками контролируете, кто и в каком порядке пишет в файл. Фактически, синхронизируете процессы вручную, подвешивая их до тех пор, пока не произойдет ввод.
Замените append на write. Для этого при открытии файла вместо std::ios::app передайте в конструктор потока std::ios::out. После этого ничего страшного не произойдет, но сохранится результат работы лишь того процесса, что был вызван из фона последним. Попробуйте разобраться, почему так происходит, чтобы не допустить такой ошибки в проекте побольше.
Аналогично, попробуйте убрать std::endl в конце выводов. Он используется для того, чтобы заставить буфер наконец записать в файл все, что в этот буфер накидали через cout/printf. Если его нет, то данные продолжают копиться в буфере, а в файл не попадают. Вначале проверьте, как это работает, на одном процессе, а потом на нескольких. Это тоже ошибка, которую нужно проверить руками, а потом не повторять.
А вот пример сильно некорректного поведения, которое стоит посмотреть вживую.
В файле 03_asynchronous_writing.cpp
приведен пример кода, который пишет в файл набор чисел.
Каждое число он пишет с новой строки и подписывает своим pid, чтобы нам было удобнее читать лог событий, который получится в 1.txt
.
Не забывайте удалять старые версии файла - все эти примеры кода только дописывают в конец и не затирают прошлую версию.
Скомпилируйте этот пример и запустите один процесс. Проверьте, что в файл все вывелось нормально.
Теперь запустите несколько процессов:
./a.out && ./a.out && ./a.out
Проверьте содержимое 1.txt
.
Да, там все аккуратно - процессы пишут в памяти друг после друга, не перемешиваясь.
Это потому, что при таком запуске один процесс ждет завершения другого.
Вы можете попробовать контролировать этот процесс при помощи ввода с экрана и убедиться в том, что новый запускается только после того, как отработал старый.
А теперь давайте посмотрим на настоящую рассинхронизацию.
Прочитайте и запустите скрипт 03_execute_asynchronously.sh
.
Он запустит несколько копий вашего процесса одновременно.
Прочитайте файл с логом - теперь записи между разными pid оказались перемешаны.
Запустите скрипт несколько раз, увеличьте количество копий процесса и посмотрите, что будет с логом.
Обычно это то поведение, которого хочется избежать. А когда не получается, тогда и появляются логи с перемешанными timestamp...
Как вы заметили, pid выдаются довольно-таки произвольным образом. Если вы хотите обмениваться какой-то информацией между потоками (в том числе, синхронизировать их автоматически), нужно, чтобы они как-то узнали, кому вообще что-то передавать - какому pid. Это можно организовать через файлы, можете попробовать что-то поизобретать, если стало интересно.
Но есть более штатный способ.
В Linux есть удобная механика порождения одних процессов другими - простая функция fork().
Как она работает, можно посмотреть в примере 04_fork.cpp
.
Почитайте этот файл, скомпилируйте его, запустите, спрячьте в фон и посмотрите через htop.
Вы увидите, что из одного процесса появилось два.
Эти процессы не совсем равноправны. Например, если вы завершаете родительский процесс через SIGKILL, то завершаются и все дочерние. А дочерний вы просто так завершить не сможете. С этой механикой можно экспериментировать дальше, создавая больше процессов (погуглите еще fork bomb).
Перед тем, как идти дальше, давайте убедимся, что в результате форка получаются разные процессы с независимыми адресными пространствами.
Почитайте, скомпилируйте и запустите файл 05_fork_with_memory.cpp
.
Он должен отъедать достаточно большой кусок памяти, чтобы заметить его невооруженным глазом в диспетчере задач.
Если вы раскомментируете форк, то каждый дочерний процесс будет съедать еще один такой же кусок.
Обратите внимание, как в этом примере работает синхронизация. Это как бы "синхронизация" через все то же ожидание ввода. Родительский процесс меняет массив до того, как подвиснет на cin. Дочерний процесс выводит его после.
Итак, мы можем создавать потоки, которые что-то друг о друге знают. Теперь можно попробовать передавать между ними данные. Для этого есть много способов, кому интересно больше - можно почитать, например, тут https://biendltb.github.io/tech/inter-process-communication-ipc-in-cpp/ Мы попробуем старые добрые пайпы.
Очень-очень минималистичный пример, жестоко ободранный от всего лишнего, лежит в 06_pipes.cpp
.
Фактически, вы создаете временный файлик, через который перекидываете числа.
Но это реализовано через автоматические системные вызовы и довольно-таки удобно.
Попробуйте прочитать-собрать-запустить, запустить несколько раз очень быстро (его начнет забавно глючить), а потом поэкспериментировать с этой механикой.
В приличном коде функции создания файлов и приёма/передачи принято обвешивать проверками. Это общение с внешними для программы сущностями, в любой момент что-то может пойти не так. Чтобы из-за этого не падать с нечитаемой ошибкой, а продолжать работу (в том числе, например, падать с читаемой ошибкой), нужно обрабатывать то, что эти функции возвращают, и не полагаться на то, что они "ну наверное сработали". Это ровно та ситуация, где возникают коды возврата, исключения, ассерты и т.д.
Обратите внимание на синтаксис всех функций, которые обращаются с процессами. Это чистый С и соответствующий стиль кода. Во всех примерах намеренно не прописан using namespace std, чтобы было явно видно, какая функция из С, а какая из С++.
Теперь, когда вы умеете связывать процессы вместе, попробуйте собрать что-нибудь на этой механике. Это полностью настоящая параллельность - вы могли уже заметить, что разные процессы нагружают разные ядра процессоров. Сделайте хотя бы одну из этих задач.
Обратите внимание, что при попытке синхронизировать процессы у вас возникнут проблемы. Постарайтесь максимально разделить работу над разными кусками данных. На этом этапе полностью допустимо вначале разбивать данные по разным файлам (по одному на процесс), потом писать в разные файлы параллельно (по одному на процесс), а потом уже последовательно собирать из этих разных файлов то, что получится в итоге. *Более правильно и быстро синхронизацию нужно делать через мьютексы и семафоры. Но это очень большие темы, к которым нужно много бэкграунда, и в этот курс они не лезут. Если кому-то стало очень интересно, можете начать с книги Конькова и Карпова "Основы операционных систем". Но если нет, то *
Если сложно и с ходу не получается, переходите к пункту 3. Та механика, на самом деле, попроще, но попробуйте вначале воспользоваться этой.
- Обработка картинки из примера
07_sorting_pixels.cpp
. Код уже есть, надо его распараллелить. - Сортировка большого массива. (Непростое. Не всякая сортировка параллелится хорошо. Посмотрите, например, radix sort.)
- Другая обработка картинки. (Яркость, контрастность, поменять цвета, что угодно.)
- Перемножение матриц.
- ...
В этом пункте вам надо не только проверить работоспособность написанного алгоритма, но и замерить время и построить график времени работы от количества процессов.
Еще одна механика низкоуровневого распараллеливания, которая штатно приехала в С++ в 11 стандарте. До этого она была в boost, тоже удобно, но не настолько. На самом деле, чтобы занять несколько ядер, вам не обязательно создавать полноценные процессы с независимым адресным пространством. Можно создавать несколько тредов (threads, потоки) в рамках одного процесса. Каждый тред будет "висеть" на отдельном ядре процессора.
А вот с памятью есть нюансы. Если вы не помните, что такое сегменты памяти в рамках плоской модели памяти, лучше вспомните, там довольно коротко (https://youtu.be/TZ2ZGfWRsHQ?list=PLthfp5exSWEqMwhBP0K-djeFzp9wuKJ0_&t=2261). Так вот, у тредов будет общий сегмент глобальных переменных, общий сегмент кода и общая куча. Но у каждого треда будет свой сегмент стека.
Вообще, механика тредов нужна для того, чтобы операционная система могла распределять вычислительные ресурсы. Поэтому все процессы, которые вы до сих пор запускали, на самом деле уже использовали треды - по одному на процесс. Давайте теперь посмотрим простые примеры, как бы этих тредов сделать несколько.
Опять же, максимально ободранный минимальный пример лежит в 08_threads.cpp
.
Собирать нужно с флагом -pthread
, иначе будут ошибки линковки.
Он показывает, как создать тред, а потом дождаться конца его выполнения в точке синхронизации.
Такие точки нужны, потому что только в них вы уверены, что тред отработал до конца, и с данными можно работать дальше.
Чтобы убедиться, что с кучей тоже все в порядке (она общая), посмотрите пример 09_threads_heap.cpp
.
Посмотрите - в смысле, как обычно, почитайте, скомпилируйте, запустите, поковыряйте, поэкспериментируйте.
Придумайте способ, как проверить, один это процесс или все-таки несколько. Дописывайте код, смотрите в диспетчер задач, анализируйте то, что видите.
Запустите предыдущий пример несколько раз. Раз 10-20. Если вы очень везучий человек, то вы поймаете один очень характерный баг. Из-за того, что у вас идет два параллельных потока инструкций, а данные никак не огорожены, то конечный результат (содержимое общей переменной) может зависеть от порядка доступа. На быстрых операция типа +=1 его поймать сложно, поэтому давайте посмотрим на пример, где эта проблема воспроизводится гарантированно.
Посмотрите пример 10_threads_timings.cpp
, попробуйте поменять таймеры, добавьте отладочный вывод при необходимости.
Сложите у себя в голове картину, что происходит, и в каком порядке треды пишут свои данные в переменную.
Теперь вам будет более понятно, что происходит в 11_threads_race_condition.cpp
.
Там происходит race condition.
Это стандартное название для ситуации, когда несколько потоков одновременно пишут в одну и ту же память, и результат будет отличаться в зависимости от случайности - какой поток получит доступ раньше, а какой позже.
Запустите несколько раз и посмотрите, как меняется итоговое значение.
Это примеры того, как делать не надо.
Итак, вы уже должны были уловить проблему. Давайте посмотрим, как чинить её штатными (и довольно удобными) средствами.
Вначале посмотрите на пример 12_threads_atomic.cpp
.
Это починка 08_threads.cpp
- если глобальную переменную сделать атомиком, то разные треды будут работать с ней корректно и предсказуемо.
А теперь пример 13_threads_race_condition_atomic.cpp
.
Мы добавили в 11_threads_race_condition.cpp
атомик, но это, как вы можете легко убедиться, не помогло.
Постарайтесь сами понять, почему, и попробуйте починить сами, не заглядывая в 14_threads_race_condition_fixed.cpp
.
В целом, атомики - не универсальное лекарство, и головой приходится думать, но это крайне полезная механика. Про её детали (как жонглировать тредами) можете почитать тут https://en.cppreference.com/w/cpp/atomic/atomic
А теперь попробуйте сделать то же самое, что в пункте 2.5, только с тредами. Скорее всего, из-за общей памяти это будет гораздо проще.
Насчет производительности.
Чтобы проверить, насколько эффективно вы нагружаете ваш процессор, нужно посмотреть количество физических ядер.
Как и в лабе про make, вам нужна команда lscpu
.
Там нужно смотреть на эти строки:
Thread(s) per core: 2
Core(s) per socket: 6
Socket(s): 1
Да, это те же самые треды. Но то, что их по два на ядро (гипертрединг), не значит, что это ядро будет работать в два раза быстрее. Там просто немного эффективнее делятся ресурсы. Максимальное ускорение будет только от физических ядер - то есть количество сокетов, умноженное на количество ядер на сокет. Если у вас сложный ноутбуковый процессор с отдельным комплектом энергосберегающих ядер, картина становится сильно сложнее. Попробуйте разобраться, когда включаются какие, используя простые алгоритмы и графики их производительности.
Как вы уже могли понять, это сильно разные механики распараллеливания с точки зрения кода и удобства работы. Если нужна большая степень независимости и мало передачи данных, лучше процессы. Если нужно много данных перекидывать туда-сюда, лучше треды. При выборе инструмента, как и всегда, ориентируйтесь на то, чего вы хотите добиться в итоге.
В том случае, если вам нужно распараллелить вашу программу не в рамках одной машины с одним многоядерным процессором, а на большом кластере из нескольких машин (сотен и даже тысяч процессоров), вам понадобится библиотека MPI. Она похожа на работу с процессами, только сложнее, со своими сложностями и нюансами. Поэтому в рамках этого курса мы даже не пытаемся её погрызть, но если вы освоились с тем, как синхронизировать процессы и выбивать из них приличную производительность, то и на отдельных курсах про MPI, которые у вас будут, вам будет гораздо понятнее, что происходит.
А треды вам пригодятся гораздо раньше. Это простая и неплохо работающая параллельность, которую вы можете прикрутить даже к небольшим расчетным проектам. Ещё, например, сейчас часто раскидывают на отдельные треды отрисовку и обсчет игровых механик. Главное - постарайтесь минимизировать совместное использование памяти разными тредами. Чем они независимее, тем вам спокойнее.
Обратите особое, нет, особое внимание на вопросы безопасности и синхронизации. Обычно под безопасностью в данном контексте имеется в виду безопасность ваших собственных данных в рамках вашего же процесса. Систему вы так не сломаете, если не умеете пользоваться низкоуровневыми системными библиотеками и не запускаете ничего через sudo. Но если вы просто накидываете многопоточность, не задумываясь о том, как эти разные потоки будут работать с общей памятью, вы можете поймать очень коварные ошибки, и никакие инструменты анализа кода вам в этом не помогут. Их даже воспроизвести тяжело. А если ошибка произошла не у вас на домашней машине, а на нефтяной вышке в Воркуте или на спутнике, и из-за этого была потерян сигнал телеметрии... Если вы хотите дальше прокачиваться в этой теме - да, основная прокачка будет завязана как раз на эти вопросы. Не удивляйтесь, что опытные программисты относятся к этому серьезно. Вам тоже стоит.
На половину плюса - одна задача из 2.5, выполненная на тредах. Не забудьте графики.
На полный плюс - то же самое, только на процессах. И тут тоже графики.