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

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

Далее, задачи могут конфликтовать друг с другом за те или иные ресурсы (в частности, за оборудование). При рассмотрении типов многозадачности, мы уже рассматривали типовые случаи конфликтов за порт SPI (частично решаемое переходом на кооперативную многозадачность, но на неё перейти можно не всегда).

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

Во всех этих случаях, на помощь разработчику приходят синхронизирующие объекты. Давайте в текущей публикации рассмотрим, какие синхронизирующие объекты и функции имеются в ОСРВ МАКС.

Для тех, кто ещё не видел предыдущие части, ссылки:

Часть 1. Общие сведения
Часть 2. Ядро ОСРВ МАКС
Часть 3. Структура простейшей программы
Часть 4. Полезная теория
Часть 5. Первое приложение
Часть 6. Средства синхронизации потоков (настоящая статья)
Часть 7. Средства обмена данными между задачами
Часть 8. Работа с прерываниями

Критическая секция


Для разминки, рассмотрим класс CriticalSection. Он применяется для обрамления участков, на которых недопустимо переключение контекста.

Как только объект класса появляется в области видимости, блокируются все прерывания с приоритетом MAX_SYSCALL_INTERRUPT_PRIORITY или ниже. На сегодня эта константа равна пяти, что блокирует все прерывания от устройств и от системного таймера, но не блокирует исключительные ситуации.

Когда объект выходит из области видимости, уровень прерываний восстанавливается такой, какой был до входа. Последнее позволяет не заботиться о том, что получатся вложенные критические секции — реально сработает только первая. Все, кроме внешней — будут выполнять ненужную работу. Сначала заменять высокий уровень разрешённых прерываний на него же самого, а при выходе — восстанавливать исходный — всё тот же исходно высокий. Выход же из самой внешней из вложенных критических секций, восстановит низкий уровень. Само собой, вложенность чаще всего получается, когда вызвана какая-то функция, в которой также есть критическая секция.

Так как критическая секция изменяет приоритеты прерываний, то есть, программирует NVIC, она должна использоваться только в коде, работающем в привилегированном режиме.

Рассмотрим пример (участок, выделенный розовым, защищён критической секцией, здесь гарантируется, что управление у задачи не будет забрано)



То же самое - текстом
void ProfEye::Tune()
{
	ProfData::m_empty_call_overhead = 0;
	ProfData::m_empty_constr_overhead = 0;
	ProfData::m_embrace_overhead = 0;
	
	CriticalSection _cs_;
	
	loop ( int, i, 1000 ) {		
		PROF_DECL(PE_EMPTY_CALL, empty_call);
		PROF_START(empty_call);
		PROF_STOP(empty_call);
		
		{ PROF_EYE(PE_EMBRACE, _embrace_); 
			{ PROF_EYE(PE_EMPTY_CONSTR, _empty_constr_); 
			}	
		}	
	}
	ProfData::m_empty_call_overhead = prof_data[PE_EMPTY_CALL].TimeAvg();
	ProfData::m_empty_constr_overhead = prof_data[PE_EMPTY_CONSTR].TimeAvg();
	ProfData::m_embrace_overhead = prof_data[PE_EMBRACE].TimeAvg() + ProfData::ADJUSTMENT - 2 * ProfData::m_empty_constr_overhead;
}


Разумеется, область видимости объекта всегда можно ограничить фигурными скобками.



Текстом
ProfEye::ProfEye(PROF_EYE eye, bool run)
{ 
	m_eye = eye; 
	m_lost = 0;
	m_run = false; 
	if ( run ) { 
		{ CriticalSection _cs_;
			prof_data[m_eye].Lock(true);
			m_up_eye = m_cur_eye; 
			m_cur_eye = this;
		}
		Start(); 
	} else 
		m_up_eye = nullptr; 
}	


Что подлежит включению в критическую секцию? Ну, например, операции, где требуется атомарно изменить несколько переменных (пример выше как раз и занимается этим). Различные списки и прочие вещи, где одна задача читает, а вторая — пишет, вполне могут требовать атомарного доступа.

Но существуют и менее очевидные вещи. Пусть имеется некий счётчик, который в одной задаче увеличивается, а в другой — уменьшается. Казалось бы, вполне себе атомарная операция

cnt++;

Но атомарная она на уровне языка высокого уровня. На уровне ассемблера, она распадается на операции чтение-модификация-запись

26: cnt++;
0x08004818 6B60 LDR r0,[r4,#0x34]
0x0800481A 1C40 ADDS r0,r0,#1
0x0800481C 6360 STR r0,[r4,#0x34]

Допустим, значение (для верности, скажем 10) уже попало в регистр r0, после чего планировщик передаст управление другой задаче. Она также считает значение 10 и уменьшит его, положив 9. Затем, когда управление вернётся текущей задаче, она прибавит единицу не к переменной, а к тому, что уже попало в регистр r0 — к десятке. Получится 11. Значение счётчика окажется искажено.

Вот как раз для защиты от таких ситуаций, вполне подойдёт критическая секция.

{
   CriticalSection cs;
   cnt++
}

Хотя, конечно, накладные расходы на работу с NVIC также следует держать в уме, так как получится не три, а намного больше команд ассемблера

27: CriticalSection cs;
0x0800481A 4668 MOV r0,sp
0x0800481C F7FEFCE8 BL.W _ZN4maks15CriticalSectionC2Ev (0x080031F0)
28: cnt++;
0x08004820 6B60 LDR r0,[r4,#0x34]
0x08004822 1C40 ADDS r0,r0,#1
29: }
0x08004824 6360 STR r0,[r4,#0x34]
0x08004826 4668 MOV r0,sp
0x08004828 F7FEFDA0 BL.W _ZN4maks19InterruptMaskSetterD2Ev (0x0800336C)

Это не считая содержимого системных подпрограмм… Приведём только первую, чтобы читатель представлял её сложность

0x080031F0 B510 PUSH {r4,lr}
0x080031F2 2150 MOVS r1,#0x50
0x080031F4 F000F8AE BL.W _ZN4maks19InterruptMaskSetterC2Ej (0x08003354)
0x080031F8 4901 LDR r1,[pc,#4] ; @0x08003200
0x080031FA 6001 STR r1,[r0,#0x00]
0x080031FC BD10 POP {r4,pc}

Как видно — это ещё не максимальная вложенность… С другой стороны, это — всё равно меньшее из зол. Просто не стоит увлекаться частыми входами и выходами из критической секции.

Критическая секция — очень мощный, но потенциально опасный инструмент, ведь пока она не выйдет из области видимости — многозадачность отключается. В идеале, блокировку следует выполнять только на несколько строк. Наличие цикла может существенно увеличить время задержки, вход в функции — и подавно (если программист слабо представляет себе время нахождения в этих функциях), а уж работа с некоторыми видами оборудования — совсем потенциально опасная вещь. Пусть программист решил гарантировать себе отсутствие переключения контекста на время передачи двух байтов по шине SPI с частотой 10 МГц. Один бит имеет период 100 нс. 16 бит — 1,6 мкс. Это вполне приемлемый результат. Следующая задача потеряет не более этого участка (в целом, это сопоставимо со временем работы планировщика). Но если передавать по UART строку из 20 символов на скорости 250 килобит в секунду, то это займёт уже 20 * 10 * 4 мкс = 0.8 мс. То есть, начнись процесс ближе к концу кванта времени задачи, он «съест» почти весь квант следующей задачи.

В общем, механизм критических секций — достаточно мощный, но программист, использующий его, несёт всю ответственность за обеспечение работы системы в реальном времени.

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

Рассмотрим простейший пример. Уже известная задача, изменяющая состояние порта, периодически вызывающая функцию задержки с блокировкой:

	virtual void Execute()
	{
		while (true)
		{
			GPIOE->BSRR = (1<<nBit);
			Delay (5);
			GPIOE->BSRR = (1<<(nBit+16));
			Delay (5);
		}
	}

Даёт нормальный меандр:



Добавляем критическую секцию



Текстом
	virtual void Execute()
	{
		CriticalSection cs;
		while (true)
		{
			GPIOE->BSRR = (1<<nBit);
			Delay (5);
			GPIOE->BSRR = (1<<(nBit+16));
			Delay (5);
		}
	}


Получаем совершенно иной сигнал



Увеличиваем масштаб — период сигнала совершенно неверен…



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

Как-то затянулась разминка. Простейшая вещь растеклась на кучу листов. Что ж, переходим к чуть более сложной по логике, но зато требующей меньше текста, вещи — двоичному семафору.

Двоичный Семафор


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



Такой семафор реализуется классом BinarySemaphore.

Конструктор класса содержит обязательный аргумент, задающий начальное состояние семафора

explicit BinarySemaphore(bool is_empty = true)

Дальше мы рассмотрим только функции двоичного семафора (дело в том, что он является наследником простого семафора, и всю его функциональность мы рассмотрим ниже).

Задачи, которым следует пройти через семафор, должны вызвать функцию Wait(). Аргументом этой функции является время таймаута в миллисекундах. Если за заданное время задача была разблокирована, функция вернёт значение ResultOk. Соответственно, если наступил таймаут, результат функции будет равен ResultTimeout. Когда функция должна ждать «до упора», следует передать значение таймаута, равное INFINITE_TIMEOUT.

Если функция вызывается с нулевым значением таймаута, то она вернёт управление мгновенно, но по результату (ResultOk или ResultTimeout) будет ясно, был семафор открыт или закрыт.

При вызове из прерывания, блокировка задачи невозможна, поэтому с любым таймаутом, отличным от нуля, результат функции будет равен ResultErrorInterruptNotSupported. Однако, с нулевым таймаутом функцию можно вызывать и из прерывания.

Если функция вернула результат ResultOk, семафор автоматически закроется.

Как уже отмечалось, ждать семафора может сразу несколько задач. В этом случае, выбор «счастливчика», которого пропустят первым, будет осуществляться следующим образом: Задачи в списке ожидания следуют в порядке уменьшения приоритета, а при одинаковом приоритете — в порядке вызова функции Wait().

Для того, чтобы открыть семафор, используется функция Signal(). Если семафор уже открыт, она вернёт результат ResultErrorInvalidState, иначе — ResultOk. Функция не может вызываться из прерывания с приоритетом выше, чем MAX_SYSCALL_INTERRUPT_PRIORITY.

Семафор


Честно говоря, мне категорически не нравится это название. Правильнее было бы назвать этот объект синхронизации «Завхоз», но против традиций не попрёшь. Везде он называется семафором, ОСРВ МАКС не является исключением. Отличие простого семафора (или завхоза) от двоичного состоит в том, что он может считать (класть ресурсы на склад). Есть хотя бы один ресурс — задача может проходить (при этом число ресурсов уменьшается). Нет ресурсов — задача будет ждать появления хотя бы одного.



Такие семафоры пригождаются, когда необходимо распределять какие-либо ресурсы. Я, например, заводил 4 буфера для выдачи данных в USB (эта шина работает не с байтами, а с массивами байтов, поэтому удобнее всего готовить данные именно в буферах). Соответственно, рабочая задача может определить, есть ли свободные буфера. Если есть — заполнить их. Нет — ждать, пока, работающий с USB перешлёт и освободит хотя бы один. В общем, если где-то выделяется не один, а несколько ресурсов — их распределение удобнее всего поручить завхозу (или, согласно традиционному именованию — семафору).

Соответственно, этот объект реализуется в классе Semaphore. Рассмотрим его отличие от двоичного семафора. Во-первых, у него чуть иной конструктор

Semaphore(size_t start_count, size_t max_count)

Первый параметр — сколько вещей исходно положить в ящик, второй — какова размерность этого ящика. Иногда полезно при начале работы объявить полное отсутствие ресурсов, а затем — добавлять их (вызывая, как мы помним, функцию Signal()). Иногда — наоборот, исходно ресурсы загружены по максимуму, а дальше — они расходуются. Возможны и иные варианты с неполной начальной загрузкой, но это уже читатели сами придумают, когда такое нужно.

Функция Signal(), соответственно, увеличивает счётчик ресурсов. Если он дошёл до максимума — она вернёт ResultErrorInvalidState. Ещё раз напомним, что функция не может вызываться из прерываний с приоритетом выше, чем MAX_SYSCALL_INTERRUPT_PRIORITY.

Функция Wait() пропустит задачу, если число ресурсов не равно нулю, при этом, уменьшив счётчик. А если ресурсов нет — задача будет заблокирована, пока их не вернут через функцию Signal(). Ещё раз напомним, что из прерывания эту функцию можно вызывать только с нулевым таймаутом.

Теперь рассмотрим функции, которые не имели смысла в двоичном случае.

GetCurrentCount() вернёт текущее значение счётчика ресурсов

GetMaxCount() вернёт максимально возможное значение счётчика (если семафор создавала другая задача — может быть полезно, чтобы определиться с его характеристиками)

Мьютекс


Название этого объекта произошло от слов Mutualy Exclusive. То есть, с его помощью система предоставляет взаимоисключающий доступ к одному ресурсу. Если в семафорах одни задачи ожидали разршения, а другие — открывали семафор, то в этом случае — все пытаются ресурс захватить, а предоставляет его сама система.

Коротко суть мьютекса можно пояснить фразой «Кто первый встал — того и тапки». Есть защищаемый объект — «тапки». Муж проснулся, запросил их — система отдала их ему в безраздельное пользование. Жена и сын запросили — их заблокировали. Как только муж вернул тапки в систему — их получила жена. Вернула она — получил сын. Вернул он — объект перешёл в свободное состояние, следующий запросивший, получит их снова без ожидания.

В микроконтроллерах замечательным ресурсом, который надо защищать таким способом, является порт (SPI, I2C и т.п.), если через него пытается работать несколько задач. Мы уже рассматривали, что к одному физическому каналу может быть подключено несколько разнородных устройств, например, в классическом телевизоре: на одной шине I2C могут быть видеопроцессор, аудиопроцессор, процессор телетекста, тюнер — они вполне могут обслуживаться разными задачами. Зачем тратить процессорное время, ожидая сброса бита BSY? Тем более, что всё равно, возможны коллизии. Рассмотрим работу трёх задач, исключительно анализирующих бит BSY порта, исполняя их по шагам:



Как видно, на шаге 7 сразу две задачи пытаются управлять шиной. Если бы та была защищена мьютексом, этого бы не произошло. Кроме того, на условном шаге 1 (на самом деле, это масса шагов, где заблокирована то задача 2, то задача 3) задачи впустую тратили кванты времени. Мьютекс решает и эту проблему — все ждущие задачи блокируются.

Иногда может получиться так, что разработчик слишком увлёкся мьютексами, и задача может захватить мьютекс несколько раз. Разумеется, скорее всего, это будет происходить во вложенных функциях. Функция 1 захватывает мьютекс, потом управление передаётся в функцию 2, оттуда — в функцию 3, оттуда — в функцию 4 (написанную год назад), которая также пытается захватить этот же мьютекс. Чтобы не возникло блокировки, в таких случаях следует создавать рекурсивные мьютексы. Одна задача сможет их захватывать многократно. Важно лишь освободить столько же раз, сколько он был захвачен. В ОС Windows все мьютексы являются рекурсивными, но такой подход на слабых микроконтроллерах привёл бы к неоправданному расходованию ресурсов, поэтому по умолчанию, в ОСРВ МАКС мьютексы рекурсивными не являются.

Рассмотрим основные функции класса Mutex. В первую очередь — его конструктор

Mutex(boolrecursive = false);

Аргумент конструктора определяет тип — рекурсивный или нет.

Функция Lock() захватывает мьютекс. В качестве аргумента передаётся значение таймаута. Как всегда, можно задать особые значения — нулевой таймаут (мгновенный выход без ожидания) или значение INFINITE_TIMEOUT (ждать до победы). Если мьютекс удалось захватить, будет возвращён результат ResultOk. При истечении таймаута, вернётся результат ResultTimeout. При попытке захвата нерекурсивного мьютекса, результат будет ResultErrorInvalidState. Мьютекс нельзя захватывать в прерывании. Если попытаться это сделать, результат будет ResultErrorInterruptNotSupported.

Функция Unlock() — освобождает мьютекс. Соответственно, она должна вызываться по окончании исполнения защищаемой секции.

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

Теперь стоит рассказать про такую вещь, как наследование приоритетов. Допустим, в системе имеются задачи A с нормальным приоритетом, B с повышенным приоритетом и C с высоким приоритетом. Предположим, что задачи B и C были заблокированы, а A в это время успела захватить мьютекс. Нарисуем это графически, расположив задачи друг над другом (чем выше приоритет, тем выше задача на рисунке)



Теперь задача C разблокировалась. Само собой, имея наивысший приоритет, она начала исполняться. И допустим, она тоже собирается захватить тот же самый мьютекс



Но мьютекс находится во владении задачи A! По штатной логике, раз он занят, задача C блокируется до его освобождения. И вдруг неожиданно разблокировалась задача B (пусть она ждала какой-то другой ресурс, и тот освободился). Так как её приоритет выше, чем у A, то исполняться будет именно она (то есть, задача B)



Что мы имеем? Высокоприоритетная задача C не может исполняться, так как она ждёт освобождения мьютекса. А дождаться она не может, так как его текущий владелец вытеснен более высокоприоритетной задачей относительно владельца мьютекса, но более низкоприоритетной — относительно несправедливо заблокированной задачи.

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



Этот факт крайнее важно помнить, так как если задача A взаимодействует также с какой-либо задачей E, также имеющей нормальный приоритет, на время наследования приоритета, она это взаимодействие утеряет. Увы, с этим ничего сделать невозможно (в рамках стандартной концепции ОСРВ), это следует просто учитывать при проектировании программ.

Мьютекс-страж


Классическое алгоритмическое программирование подразумевает один вход и один выход в любом алгоритме. Однако, практика такова, что фанатичное обеспечение этого принципа приводит к неоправданному усложнению текста и снижению читаемости. Давайте рассмотрим следующий псевдокод:


	m_mutex.Lock();
	switch (cond)
	{
	   case 0x00:
   	      ....
   		return ResultCode1;
           case 0x02:
   	      ....
               return ResultCode2;
           case 0x0a:
   	      ....
               return ResultCode3;
           case 0x15:
   	      ....
               return ResultCode4;
	}
        ....
        m_mutex.Unlock();

Вообще-то, здесь перед каждым выходом из функции, следует методично расставить mutex.Unlock(). А таких участков в большом алгоритме может быть много. И они могут добавляться. Рано или поздно, программист где-нибудь забудет разблокировать мьютекс, и программа «зависнет». И это — при том, что человечество ночей не спало, изобретало ООП в целом и деструкторы классов в частности!

Мьютиекс-страж как раз занимается тем, что использует деструкторы. У этого класса в интерфейсной части нет ничего, кроме конструктора. Скопируем его описание из Руководства Программиста:
explicit MutexGuard(Mutex& mutex, bool only_unlock = false);

Аргументы:

  • mutex – ссылка на мьютекс;
  • only_unlock–если значение – истина, захват мьютекса в конструкторе не производится. Подразумевается, что мьютекс уже был захвачен ранее явным вызовом метода Lock().

В конструктор следует передать мьютекс. Он будет захвачен. А отпущен он будет в тот момент, когда мьютекс-страж выйдет из области видимости. Таким образом, предыдущий пример следует переписать примерно так:


Текстом
       {
	MutexGuard (m_mutex);
	switch (cond)
	{
	   case 0x00:
   	      ....
   		return ResultCode1;
           case 0x02:
   	      ....
               return ResultCode2;
           case 0x0a:
   	      ....
               return ResultCode3;
           case 0x15:
   	      ....
               return ResultCode4;
	}
        ....
        }


Событие


Семафоры и мьютексы обычно используются для того, чтобы исключить конкуренцию задач при использовании тех или иных ресурсов, но иногда просто следует заблокировать задачу до тех пор, пока не возникнет какое-либо условие для её разблокировки. Типовой сценарий – произошло прерывание и функция-обработчик сигнализирует высокоприоритетной задаче отложенной обработки, что следует пробудиться и выполнить какие-то действия. События позволяют реализовать данный механизм.

После прочтения предыдущего абзаца, может показаться, что событие просто дублирует семафор. Но это не так. Давайте рассмотрим отличия.

Первое отличие: Событие воздействует только на того, кто его ждёт. Если в текущий момент у события нет получателя — оно уйдёт в никуда. Если через мгновение после возникновения события, кто-то начнёт ждать его — он окажется заблокирован. Разблокировка произойдёт только по следующему событию. Кто не успел — тот опоздал. Как мы помним, семафор же наоборот, ждал его кто-то или нет — всё равно откроется. И первый, кто будет проходить мимо семафора, будет пропущен.

Второе отличие — если открытия семафора ждёт несколько задач, то разблокирована будет только одна из них. Остальные будут ждать следующего открытия. Событие же можно настроить на режим, когда оно разблокирует всех, кто ждал его возникновения. То есть, все ждущие задачи будут переведены из состояния «Заблокирована» кто-то в состояние «Активна», а самая везучая задача — в состояние «Исполняется».

В остальном — логика событий напоминает логику работы двоичного семафора.

Конструктор класса:

Event(bool broadcast = true);

Параметр broadcast задает правило, по которому информируются получатели, ждущие возникновения события. true– будут разблокированы все задачи, ожидающие его, false–будет разблокирована только одна задача, которая находится первой в очереди ожидающих этого события.

Функция Raise() посылает событие. Не может быть вызван из прерывания, с приоритетом выше, чем MAX_SYSCALL_INTERRUPT_PRIORITY.

Функция Wait() уже знакома нам по одноимённым функциям ранее рассмотренных синхронизирующих объектов. Точно так же имеет аргумент таймаута. Точно так же таймаут может быть равен нулю, либо INFINITE_TIMEOUT. Эту функцию нельзя вызывать из прерываний.

Примеры работы с синхронизирующими объектами


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

В запланированной третьей части должно будет появиться описание адаптации большой программы для станка с ЧПУ, оставим примеры для неё. А для тех, кто жаждет практики, я могу порекомендовать модульные тесты для ОС. Они размещаются в каталоге ...\maksRTOS\Source\Tests\Unit tests. Вот перечень каталогов, размещённых там:

BinarySemaphore
Event
MessageQueue
Mutex
MutexGuard
Scheduler
Semaphore


Лучшие практические примеры сложно придумать. Приятного изучения (правда, есть мнение, что редкий читатель дойдёт до конца первой четверти тестов).

Комментарии (1)


  1. telhin
    30.09.2017 09:51
    +1

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