Сообщений 0    Оценка 130        Оценить  
Система Orphus

Await && Locks

Достоинства и недостатки

Автор: Dmytro Sheyko
Источник: sourceforge.net
Опубликовано: 30.12.2002
Исправлено: 13.03.2005
Версия текста: 1.0
Предисловие
Обоснование единственного монитора
Конструкции или примитивы синхронизации?
Пока еще нерешенные задачи
Bulk lock
Male/female lock
Resource selection

Предисловие

Следует признать, что код, использующий библиотеку Await && Locks, не столь эффективен, насколько в принципе мог бы быть. Правда, этот недостаток относится больше именно к реализации. Тем не менее, это может стать решающим фактором при принятии решения, использовать ли данную библиотеку или нет. Обобщая, можно поставить вопрос так: «Стоят ли те преимущества, которые дает использование Await && Locks, тех накладных расходов, которые при этом возникнут?». Однозначный ответ я дать не могу. Это зависит от задач, которые собирается решать разработчик. Итак, в чем же заключаются недостатки, и какие выгоды сулит Await && Locks? Рассмотрим особенности данной библиотеки с разных сторон.

Обоснование единственного монитора

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

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

Использование отдельных мониторов приводит к усложнению кода, поскольку разработчик thread-aware классов, не всегда в состоянии предусмотреть каким монитором экземпляр данного класса будет контролироваться, т.е. придется либо хранить ссылку на монитор как член-данное, либо тянуть ее за собой по всем методам. Но самое неприятное еще впереди. Код, использующий отдельные мониторы, тяжело сопровождать, поскольку при любом изменении содержимого конструкций типа await необходимо также проверить, все ли переменные, к которым осуществляется доступ в этой конструкции, относятся к одному монитору. Если же нет, то придется заново перераспределять переменные по мониторам. Все программисты склонны ошибаться, а ошибки такого рода трудноуловимы.

И еще одна неприятность. Наличие одного монитора гарантирует отсутствие дедлоков, связанных с порядком занятия этих мониторов. При наличии нескольких мониторов, эта гарантия отсутствует. Можно обнаруживать и эти дедлоки, так же как и обнаруживаются дедлоки в конструкциях типа lock_it, но при этом усложнится реализация монитора. Можно также запретить использовать вложенные конструкции await, но это слишком жесткое требование для языка, в котором за, казалось бы, безобидным присваиванием может скрываться огромная работа. Кроме того, я сделал монитор рекурсивным именно из-за того, что, оказывается, тяжело отследить, занят сейчас монитор или нет.

Получается, отдельные мониторы создают проблем больше, чем решают. В связи с этим, я считаю, использование единой критической секции (т.е. одного монитора) оправданным. При разработке Await && Locks я стремился, во-первых, сделать пользование библиотекой удобным, во-вторых, предложить корректную реализацию, и, в-третьих, сделать реализацию как можно более эффективной. Эффективность – не самый главный критерий, которым я руководствовался. Кому нужны программы, которые работают пусть и эффективно, но неправильно? Хотя мне непонятно, о какой эффективности в этом случае может идти речь.

Конструкции или примитивы синхронизации?

Альтернативой конструкциям Await && Locks являются примитивы синхронизации, предоставляемые ОС или поточной библиотекой. Смешивание синхронизирующих конструкций и примитивов я считаю неразумным. Во-первых, потому, что конструкции Await && Locks по выразительной мощности не уступают примитивам ОС. А во-вторых, синхронизирующие конструкции структурируют взаимодействие потоков. Они настолько же важны, насколько и «родные» управляющие конструкции языка.

Полагаю, что здесь уместно провести аналогию между примитивами синхронизации и пресловутым оператором goto. Используя стиль goto можно в принципе писать корректные и эффективные программы. Но сопровождение таких программ (по-настоящему больших и сложных программ) – сущий ад. Использование же структурного стиля позволяет писать не намного менее эффективные программы. Однако предсказать состояние такой программы на каждом участке кода гораздо проще. Так, например, совершенно очевидно, что значение предиката условного оператора на ветке then будет истинным, а на ветке else – ложным; а тело цикла будет выполняться только при истинном значении условия продолжения цикла.

Многопоточность привносит в программы недетерминированность, дополнительную непредсказуемость. Использование же конструкций типа await позволяет программисту опираться на гарантии того, что некий (определенный) предикат будет истинным на момент выполнения некой (определенной) инструкции. И при этом, становится совершенно очевидно, какое же событие ждет поток на данной конструкции await. А это избавляет программиста, сопровождающего данную программу, от необходимости анализировать весь код в поисках потока, который это событие генерит. Я рассчитываю на то, что конструкции Await && Locks превратят multithreaded spaghetti в четкие, ясные, понятные и красивые программы.

Кроме конструкций типа await, библиотека Await && Locks предоставляет также конструкции типа lock_it, ценность которых не стоит умалять. Думаю, что возможности этих конструкций по достоинству оценят те, кто хотя бы однажды почувствовал, что ему не хватает блокировок чтения в Win32 или функции типа WaitForMultipleObjects на платформах, отличных от Windows. Я старался объединить достоинства различных многопоточных API, и считаю, что в блокировках мне это удалось. Но и это еще не все. Конструкции lock_it обнаруживают дедлоки! На данный момент я не знаю приемлемого способа писать программы, в которых гарантированно отсутствовала бы возможность войти в дедлок. Но то, что такие ошибки обнаруживаются библиотекой и обрабатываются, – несомненно, ее достоинство. Ну и, наконец, еще один маленький плюс. Конструкции lock_it можно использовать в уже написанных программах даже без изменения структур данных.

Недостатком конструкций lock_it является то, что с их помощью нельзя заблокировать ресурс в одной функции, а освободить в другой. Однако в таком случае можно воспользоваться напрямую LockManager’ом. При этом, поскольку Await && Locks не может контролировать занятую блокировку, занятые ресурсы придется освобождать самому.

Пока еще нерешенные задачи

Несмотря на то, что конструкции типа lock_it являются мощными и выразительными конструкциями, существует ряд задач, связанных с блокировками, которые не выражаются (или выражаются, но сложно) при помощи этих конструкций. Эти задачи решаются при помощи конструкций await, но использование lock_it было бы предпочтительным из-за особенности конструкций lock_it обрабатывать дедлоки. Можно сказать, что конструкции lock_it обладают недостаточной выразительной мощностью. Но это не принципиальный недостаток – я уверен, что это лишь временно.

Bulk lock

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

Либо блокировать весь контейнер без возможности блокировать отдельный элемент.

	Item g_oContainer[N];

	lock_it(wlock(g_oContainer)) {
		// . . .
	}

Либо блокировать каждый элемент без возможности блокировать весь контейнер сразу. При этом для того, чтобы заблокировать весь контейнер нужно явно заблокировать каждый элемент.

	LockSlot slot;
	for(int i=0; i<N; ++i)
		slot &= wlock(g_oContainer[i]);
	lock_it(slot) {
		// . . .
	}

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

Первое расширение Await && Locks, которое напрашивается для этого случая, заключается в следующем. Использовать при блокировке ресурсов не только адрес, но и размер. При этом макросы wlock и rlock будут выглядеть так:

	#define rlock(lvalue) await_n::LockSlot::makeSharedLock(&(lvalue), sizeof(lvalue))
	#define wlock(lvalue) await_n::LockSlot::makeExclusiveLock(&(lvalue), sizeof(lvalue))

А блокировки так. (Блокируется весь массив для чтения и третий элемент для записи).

	lock_it(rlock(g_oContainer) && wlock(g_oContainer[3])) {
		// . . .
	}

К сожалению, данное решение не приемлемо для таких контейнеров как std::vector или std::list, поскольку размер этих классов никак не связан с количеством элементов, которые в них содержатся.

Есть и другое решение. Давайте определимся, в каких случаях поток может занять элемент или весь контейнер для чтения или для записи. Итак,

Поток может занять контейнер для чтения, тогда и только тогда, когда никакой другой поток не занял ни весь контейнер, ни какой-либо из его элементов для записи.

Поток может занять контейнер для записи, тогда и только тогда, когда никакой другой поток не занял ни весь контейнер, ни какой-либо из его элементов ни для записи, ни для чтения.

Поток может занять элемент для чтения, тогда и только тогда, когда никакой другой поток не занял ни весь контейнер, ни этот элемент для записи.

Поток может занять элемент для записи, тогда и только тогда, когда никакой другой поток не занял ни весь контейнер, ни этот элемент, ни для записи, ни для чтения.

Далее, обозначим количество блокировок записи, наложенных на контейнер C, как whole(C).write. Количество всех блокировок, наложенных на контейнер C, – whole(C).any. Аналогично обозначим количество блокировок контейнера каждым потоком T как whole_thread(C, T).write и whole_thread(C, T).any. Теперь обозначим блокировки, наложенные на контейнер не непосредственно, а посредством блокировок отдельных элементов. Количество блокировок записи, наложенных на контейнер посредством блокировок отдельных элементов single(C).write. Количество всех блокировок – single(C).any. И для каждого потока – single_thread(C, T).write и single_thread(C, T).any. А теперь обозначим подобные количества и для каждого элемента: item(C, i).write, item(C, i).any, item_thread(C, T, i).write, item_thread(C, T, i).any.

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

   whole(C).write      whole_thread(C, T).write
   while(C).any        while_thread(C, T).any
   single(C).write     single_thread(C, T).write
   single(C).any       single_thread(C, T).any
   item(C, i).write    item_thread(C, T, i).write
   item(C, i).any      item_thread(C, T, i).any

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

	whole_other(C, T).write = whole(C).write - whole_thread(C, T).write
	whole_other(C, T).any = whole(C).any - whole_thread(C, T).any
	single_other(C, T).write = single(C).write - single_thread(C, T).write
	single_other(C, T).any = single(C).any - single_thread(C, T).any
	item_other(C, T, i).write = item(C, i).write - item_thread(C, T, i).write
	item_other(C, T, i).any = item(C, i).any - item_thread(C, T, i).any

Следует заметить, что эти 12 базовых функций не являются независимыми. Они находятся в следующих зависимостях друг от друга. Например, истинно, что для каждого контейнера C, количество блокировок записи, наложенных на него, (whole(C).write) равно сумме блокировок записи, наложенных на него каждым из потоков (whole_thread(C, T).write). Формально, такого типа зависимости можно выразить так.

	ALL(C: whole(C).write = SUM(T: whole_thread(C, T).write))
	ALL(C: whole(C).any = SUM(T: whole_thread(C, T).any))
	ALL(C: single(C).write = SUM(T: single_thread(C, T).write))
	ALL(C: single(C).any = SUM(T: single_thread(C, T).any))
	ALL(C, i: item(C, i).write = SUM(T: item_thread(C, T, i).write))
	ALL(C, i: item(C, i).any = SUM(T: item_thread(C, T, i).any))

Есть и другие зависимости, например, для каждого контейнера C, количество блокировок записи, наложенных на него всеми потоками посредством блокировок отдельных элементов, (single(C).write) равно сумме блокировок записи, наложенных всеми потоками на каждый из элементов этого контейнера (item(C, i).write).

	ALL(C: single(C).write = SUM(i: item(C, i).write))
	ALL(C: single(C).any = SUM(i: item(C, i).any))
	ALL(C, T: single_thread(C, T).write = SUM(i: item_thread(C, T, i).write))
	ALL(C, T: single_thread(C, T).any = SUM(i: item_thread(C, T, i).any))

Эти 2 типа зависимостей вместе с тем, что количество блокировок является неотрицательным целым числом, и количество блокировок записи не может превышать количество блокировок обоих типов, являются инвариантом, который не изменяется при блокировках и разблокировках.

Используя этот инвариант и 4 условия возможности занять ресурс, решение данной задачи можно выразить следующим образом.

Блокировка всего контейнера для чтения

	// блокировка
	await((whole_others(C, T).write == 0) && (single_others(C, T).write == 0)) {
		whole_thread(C, T).any++;
		whole(C).any++;
	}
	// использование
	// . . .
	// разблокировка
	await(true) {
		whole_thread(C, T).any--;
		whole(C).any--;
	}

Блокировка всего контейнера для записи

	// блокировка
	await((whole_others(C, T).any == 0) && (single_others(C, T).any == 0)) {
		whole_thread(C, T).write++;
		whole_thread(C, T).any++;
		whole(C).write++;
		whole(C).any++;
	}
	// использование
	// . . .
	// разблокировка
	await(true) {
		whole_thread(C, T).write--;
		whole_thread(C, T).any--;
		whole(C).write--;
		whole(C).any--;
	}

Блокировка отдельного элемента для чтения

	// блокировка
	await((whole_others(C, T).write == 0) && (item_others(C, T, i).write == 0)) {
		item_thread(C, T, i).any++;
		single_thread(C, T).any++;
		item(C, i).any++;
		single(C).any++;
	}
	// использование
	// . . .
	// разблокировка
	await(true) {
		item_thread(C, T, i).any--;
		single_thread(C, T).any--;
		item(C, i).any--;
		single(C).any--;
	}

Блокировка отдельного элемента для записи

	// блокировка
	await((whole_others(C, T).any == 0) && (item_others(C, T, i).any == 0)) {
		item_thread(C, T, i).write++;
		item_thread(C, T, i).any++;
		single_thread(C, T).write++;
		single_thread(C, T).any++;
		item(C, i).write++;
		item(C, T, i).any++;
		single(C, T).write++;
		single(C, T).any++;
	}
	// использование
	// . . .
	// разблокировка
	await(true) {
		item_thread(C, T).write--;
		item_thread(C, T).any--;
		single_thread(C, T).write--;
		single_thread(C, T).any--;
		item(C, i).write--;
		item(C, T, i).any--;
		single(C, T).write--;
		single(C, T).any--;
	}

Следует заметить, что временная сложность выполнения блокировок и разблокировок в данном решении не зависит линейно от количества элементов в контейнере. И в случае, если таблицы блокировок представляют собой AVL деревья, не должна превышать O(log(|C|)+log(|T|)+log(|I|)), где |C| – количество занятых контейнеров, |T| – количество потоков, и |I| – количество элементов в данном контейнере. Если же не использовать AVL деревья, а разбросать записи таблиц блокировок по контейнерам, и их элементам, и использовать Thread Local Storage, то временная сложность может достичь O(1).

Возвращаясь к конструкциям lock_it, хотелось бы видеть блокировки контейнеров и их элементов приблизительно в таком виде.

	lock_it(bulklock::whole_rlock(g_oContainer) && bulklock::item_wlock(g_oContainer, 3)) {
		// . . .
	}

Male/female lock

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

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

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

Решение выглядит так. Пусть с баней связано количество моющихся в ней мужчин и женщин.

	struct HostelBath {
		int m_nMaleCount;
		int m_nFemaleCount;
		void wash();
	};

Тогда «мужской поток» может помыться так.

	await(g_pHostelBath->m_nFemaleCount == 0)
		g_pHostelBath->m_nMaleCount++;
	g_pHostelBath->wash();
	await(true)
		g_pHostelBath->m_nMaleCount--;

А «женский поток» так.

	await(g_pHostelBath->m_nMaleCount == 0)
		g_pHostelBath->m_nFemaleCount++;
	g_pHostelBath->wash();
	await(true)
		g_pHostelBath->m_nFemaleCount--;

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

	lock_it(g_pHostelBath->malelock()) {
		// . . .
	}

А для женского так:

	lock_it(g_pHostelBath->femalelock()) {
		// . . .
	}

Resource selection

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

И решение этой задачи в более или менее общем виде можно записать так.

	Resource *pResource = 0;
	await(0 != (pResource = g_pResourcePool->findFreeResource()))
		pResource->acquire();
	pReosurce->use();
	await(true)
		pResource->release();

Хотелось бы, чтобы это решение можно было записать приблизительно так

	// вначале мы не имеем занятого ресурса
	Resource *pResource = 0;
	// указываем в блокировке переменную,
	// которая должна после содержать
	// указатель на ресурс
	lock_it(g_pResource->lock(pResource)) {
		// в этом месте ресурс уже занят,
		// т.е. переменная pResource указывает
		// на полученный ресурс, с которым можно работать
		pResource->use();
	}
	// а вот здесь мы уже не можем работать с ресурсом,
	// переменная pResource == 0

Полагаю, что подобных задач можно найти огромное множество. Исходя их этого, было бы хорошо, если бы можно было отделить семантику какого-то конкретного типа блокировок от общего механизма блокирования и разблокирования. Это позволит программисту самому создавать свои типы блокировок под конкретные нужды.


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