Издание четвертое windows ® для профессионалов создание эффективных Win32-приложений с учетом специфики 64-разрядной версии Windows


Одновременный доступ к нескольким ресурсам



Pdf просмотр
страница24/68
Дата28.11.2016
Размер3.57 Mb.
Просмотров12729
Скачиваний0
1   ...   20   21   22   23   24   25   26   27   ...   68
Одновременный доступ к нескольким ресурсам
Иногда нужен одновременный доступ сразу к двум структурам данных. Тогда
Thread
Func следует реализовать так WINAPI ThreadFunc(PVOID pvParam) {
EnterCriticalSection(&g_csNums);
EnterCriticalSection(&g_csChars);
// в этом цикле нужен одновременный доступ к обоим ресурсам for (int x = 0; x < 100; x++) g_nNums[x] = g_cChars[x];
LeaveCriticalSection(&g_csChars);
LeaveCriticalSection(&g_csNums);
return(0);
}
Предположим, доступ к обоим массивам требуется и другому потоку в данном процессе; при этом его функция написана следующим образом:
DWORD WINAPI OtherThreadFunc(PVOID pvParam) {
EnterCriticalSection(&g_csChars);
EnterCriticalSection(&g_csNums);
for (int x = 0; x < 100; x++) g_nNums[x] = g_cChars[x];
LeaveCriticalSection(&g_csNums);
LeaveCriticalSection(&g_csChars);
return(0);
}
Я лишь поменял порядок вызовов
EnterCriticalSection и LeaveCriticalSection. Но из за того, что функции
ThreadFunc и OtherThreadFunc написаны именно так, существу

206
Ч АС Т Ь I I
НАЧИНАЕМ РАБОТАТЬ
ет вероятность
взаимной блокировки (deadlock). Допустим, ThreadFunc начинает исполнение и занимает критическую секцию
g_csNums. Получив от системы процессорное время, поток с функцией
OtherThreadFunc захватывает критическую секцию
g_csChars. Тут то и происходит взаимная блокировка потоков. Какая бы из функций —
ThreadFunc или OtherThreadFunc — ни пыталась продолжить исполнение, она несу меет занять другую, необходимую ей критическую секцию.
Эту ситуацию легко исправить, написав код обеих функций так, чтобы они вызывали в одинаковом порядке. Заметьте, что порядок вызовов Leave
CriticalSection несуществен, поскольку эта функция никогда не приостанавливает поток.
Не занимайте критические секции надолго
Надолго занимая критическую секцию, Ваше приложение может блокировать другие потоки, что отрицательно скажется на его общей производительности. Вот прием,
позволяющий свести к минимуму время пребывания в критической секции. Следующий код не дает другому потоку изменять значение в
g_s до тех пор, пока в окно не будет отправлено сообщение WM_SOMEMSG:
SOMESTRUCT g_s;
CRITICAL_SECTION g_cs;
DWORD WINAPI SomeThread(PVOID pvParam) {
EnterCriticalSection(&g_cs);
// посылаем в окно сообщение, WM_SOMEMSG, &g_s, Трудно сказать, сколько времени уйдет на обработку WM_SOMEMSG оконной процедурой может, несколько миллисекунда может, и несколько лет. В течение этого времени никакой другой поток не получит доступ к структуре
g_s. Поэтому лучше составить код иначе g_s;
CRITICAL_SECTION g_cs;
DWORD WINAPI SomeThread(PVOID pvParam) {
EnterCriticalSection(&g_cs);
SOMESTRUCT sTemp = g_s;
LeaveCriticalSection(&g_cs);
// посылаем в окно сообщение, WM_SOMEMSG, &sTemp, Этот код сохраняет значение элемента
g_s во временной переменной sTemp. Нетрудно догадаться, что на исполнение этой строки уходит всего несколько тактов процессора. Далее программа сразу вызывает
LeaveCriticalSection — защищать глобальную структуру больше ненужно. Так что вторая версия программы намного лучше первой, поскольку другие потоки отлучаются от структуры
g_s лишь на несколько тактов процессора, а не на неопределенно долгое время. Такой подход предполагает,
что моментальный снимок структуры вполне пригоден для чтения оконной процедурой, а также что оконная процедура не будет изменять элементы этой структуры.

207
Г ЛАВА 9
Синхронизация потоков с использованием объектов ядра
В
предыдущей главе мы обсудили, как синхронизировать потоки с применением механизмов, позволяющих Вашим потокам оставаться в пользовательском режиме. Самое удивительное, что эти механизмы работают очень быстро. Поэтому, если Вы озабочены быстродействием потока, сначала проверьте, нельзя ли обойтись синхронизацией в пользовательском режиме.
Хотя механизмы синхронизации в пользовательском режиме обеспечивают высокое быстродействие, им свойствен ряд ограничений, и во многих приложениях они просто не будут работать. Например,
Interlocked функции оперируют только с отдельными переменными и никогда не переводят поток в состояние ожидания. Последнюю задачу можно решить с помощью критических секций, но они подходят лишь в тех случаях, когда требуется синхронизировать потоки в рамках одного процесса. Кроме того, при использовании критических секций легко попасть в ситуацию взаимной блокировки потоков, потому что задать предельное время ожидания входа в критическую секцию нельзя.
В этой главе мы рассмотрим, как синхронизировать потоки с помощью объектов ядра. Вы увидите, что такие объекты предоставляют куда больше возможностей, чем механизмы синхронизации в пользовательском режиме. В сущности, единственный их недостаток — меньшее быстродействие. Дело в том, что при вызове любой из функций, упоминаемых в этой главе, поток должен перейти из пользовательского режима в режим ядра. Атакой переход обходится очень дорого — в 1000 процессорных тактов на платформе
x86. Прибавьте сюда еще и время, которое необходимо на выполнение кода этих функций в режиме ядра.
К этому моменту я уже рассказал Вам о нескольких объектах ядра, в том числе о процессах, потоках и заданиях. Почти все они годятся и для решения задач синхронизации. В случае синхронизации потоков о каждом из этих объектов говорят, что он находится либо в свободном (signaled state), либо в занятом состоянии (nonsignaled state). Переход из одного состояния в другое осуществляется по правилам, определенным для каждого из объектов ядра. Так, объекты ядра процесс сразу после создания всегда находятся в занятом состоянии. В момент завершения процесса операционная система автоматически освобождает его объект ядра процесс, ион навсегда остается в этом состоянии.
Объект ядра процесс пребывает в занятом состоянии, пока выполняется сопоставленный с ним процесс, и переходит в свободное состояние, когда процесс завершается. Внутри этого объекта поддерживается булева переменная, которая при создании объекта инициализируется как FALSE (занято. По окончании работы процесса операционная система меняет значение этой переменной на TRUE, сообщая тем самым, что объект свободен.

208
Ч АС Т Ь I I
НАЧИНАЕМ РАБОТАТЬ
Если Выпишете код, проверяющий, выполняется ли процесс в данный момент, Вам нужно лишь вызвать функцию, которая просит операционную систему проверить значение булевой переменной, принадлежащей объекту ядра процесс. Тут нет ничего сложного. Вы можете также сообщить системе, чтобы та перевела Ваш поток в состояние ожидания и автоматически пробудила его при изменении значения булевой переменной сна. Тогда появляется возможность заставить поток в родительском процессе, ожидающий завершения дочернего процесса, просто заснуть до освобождения объекта ядра, идентифицирующего дочерний процесс. В дальнейшем Вы увидите, что весть ряд функций, позволяющих легко решать эту задачу.
Я только что описал правила, определенные Microsoft для объекта ядра «процесс».
Точно такие же правила распространяются и на объекты ядра поток. Они тоже сразу после создания находятся в занятом состоянии. Когда поток завершается, операционная система автоматически переводит объект ядра поток в свободное состояние. Таким образом, используя те же приемы, Вы можете определить, выполняется ли в данный момент тот или иной поток. Как и объект ядра процесс, объект ядра поток никогда не возвращается в занятое состояние.
Следующие объекты ядра бывают в свободном или занятом состоянии:
í
процессы
í
уведомления об изменении файлов
í
потоки
í
события
í
задания
í
ожидаемые таймеры
í
файлы
í
семафоры
í
консольный ввод
í
мьютексы
Потоки могут засыпать ив таком состоянии ждать освобождения какого либо объекта. Правила, по которым объект переходит в свободное или занятое состояние,
зависят от типа этого объекта. О правилах для объектов процессов и потоков я упоминал совсем недавно, а правила для заданий были описаны в главе В этой главе мы обсудим функции, которые позволяют потоку ждать перехода определенного объекта ядра в свободное состояние. Потом мы поговорим об объектах ядра, предоставляемых Windows специально для синхронизации потоков событиях, ожидаемых таймерах, семафорах и мьютексах.
Когда я только начинал осваивать всю эту тематику, я предпочитал рассматривать понятия свободен занят по аналогии с обыкновенным флажком. Когда объект свободен, флажок поднята когда он занят, флажок опущен.
Объект ядра
Объект ядра
С
в
о
б
о
д
е
н
З
а
н
я
т
Потоки спят, пока ожидаемые ими объекты заняты (флажок опущен. Как только объект освободился (флажок поднят, спящий поток замечает это, просыпается и возобновляет выполнение.

209
Г ЛАВА 9
Синхронизация потоков с использованием объектов ядра
З
З
з з...
Объект ядра
Объект ядра
С
в ободе н
За ня т
Wait-функции
Wait функции позволяют потоку в любой момент приостановиться и ждать освобождения какого либо объекта ядра. Из всего семейства этих функций чаще всего используется Когда поток вызывает эту функцию, первый параметр,
hObject, идентифицирует объект ядра, поддерживающий состояния свободен занят. (То есть любой объект,
упомянутый в списке из предыдущего раздела) Второй параметр,
dwMilliseconds, указывает, сколько времени (в миллисекундах) поток готов ждать освобождения объекта.
Следующий вызов сообщает системе, что поток будет ждать до тех пор, пока не завершится процесс, идентифицируемый описателем
hProcess:
WaitForSingleObject(hProcess, В данном случае константа INFINITE, передаваемая во втором параметре, подсказывает системе, что вызывающий поток готов ждать этого события хоть целую вечность. Именно эта константа обычно и передается функции
WaitForSingleObject, но Вы можете указать любое значение в миллисекундах. Кстати, константа INFINITE определена как 0xFFFFFFFF (или –1). Разумеется, передача INFINITE не всегда безопасна.
Если объект таки не перейдет в свободное состояние, вызывающий поток никогда не проснется одно утешение тратить драгоценное процессорное время он при этом не будет.
Вот пример, иллюстрирующий, как вызывать
WaitForSingleObject со значением тай маута, отличным от INFINITE:
DWORD dw = WaitForSingleObject(hProcess, 5000);
switch (dw) {
case WAIT_OBJECT_0:
// процесс завершается break;
case WAIT_TIMEOUT:
// процесс не завершился в течение 5000 мс break;
см. след. стр.

210
Ч АС Т Ь I I
НАЧИНАЕМ РАБОТАТЬ WAIT_FAILED:
// неправильный вызов функции (неверный описатель?)
break;
}
Данный код сообщает системе, что вызывающий поток не должен получать процессорное время, пока не завершится указанный процесс или не пройдет 5000 мс (в зависимости оттого, что случится раньше. Поэтому функция вернет управление либо до истечения 5000 мс, если процесс завершится, либо примерно через 5000 мс, если процесс к тому времени не закончит свою работу. Заметьте, что в параметре
dwMilli
seconds можно передать 0, и тогда WaitForSingleObject немедленно вернет управление.
Возвращаемое значение функции
WaitForSingleObject указывает, почему вызывающий поток снова стал планируемым. Если функция возвращает WAIT_OBJECT_0, объект свободен, а если WAIT_TIMEOUT — заданное время ожидания (таймаут) истекло.
При передаче неверного параметра (например, недопустимого описателя)
WaitForSing
leObject возвращает WAIT_FAILED. Чтобы выяснить конкретную причину ошибки, вызовите функцию
GetLastError.
Функция
WaitForMultipleObjects аналогична WaitForSingleObject стем исключением,
что позволяет ждать освобождения сразу нескольких объектов или какого то одного из списка объектов WaitForMultipleObjects(
DWORD dwCount,
CONST HANDLE* phObjects,
BOOL fWaitAll,
DWORD Параметр
dwCount определяет количество интересующих Вас объектов ядра. Его значение должно быть в пределах от 1 до MAXIMUM_WAIT_OBJECTS (в заголовочных файлах Windows оно определено как 64). Параметр
phObjects — это указатель на массив описателей объектов ядра.
WaitForMultipleObjects приостанавливает потоки заставляет его ждать освобождения либо всех заданных объектов ядра, либо одного из них. Параметр
fWaitAll как рази определяет, чего именно Вы хотите от функции. Если он равен TRUE, функция не даст потоку возобновить свою работу, пока не освободятся все объекты.
Параметр
dwMilliseconds идентичен одноименному параметру функции WaitFor
SingleObject. Если Вы указываете конкретное время ожидания, то по его истечении функция в любом случае возвращает управление. И опять же, в этом параметре обычно передают INFINITE (будьте внимательны при написании кода, чтобы не создать ситуацию взаимной блокировки).
Возвращаемое значение функции
WaitForMultipleObjects сообщает, почему возобновилось выполнение вызвавшего ее потока. Значения WAIT_FAILED и никаких пояснений не требуют. Если Вы передали TRUE в параметре
fWaitAll и все объекты перешли в свободное состояние, функция возвращает значение WAIT_OB
JECT_0. Если же
fWaitAll приравнен FALSE, она возвращает управление, как только освобождается любой из объектов. Вы, по видимому, захотите выяснить, какой именно объект освободился. В этом случае возвращается значение от WAIT_OBJECT_0 до +
dwCount – 1. Иначе говоря, если возвращаемое значение неравно или WAIT_FAILED, вычтите из него значение WAIT_OBJECT_0, и Вы получите индекс в массиве описателей, на который указывает второй параметр функции. Индекс подскажет Вам, какой объект перешел в незанятое состояние. Поясню сказанное на примере.

211
Г ЛАВА 9
Синхронизация потоков с использованием объектов ядра h[3];
h[0] = hProcess1;
h[1] = hProcess2;
h[2] = hProcess3;
DWORD dw = WaitForMultipleObjects(3, h, FALSE, 5000);
switch (dw) {
case WAIT_FAILED:
// неправильный вызов функции (неверный описатель WAIT_TIMEOUT:
// ни один из объектов не освободился в течение 5000 мс break;
case WAIT_OBJECT_0 + 0:
// завершился процесс, идентифицируемый h[0], те. описателем (hProcess1)
break;
case WAIT_OBJECT_0 + 1:
// завершился процесс, идентифицируемый h[1], те. описателем (hProcess2)
break;
case WAIT_OBJECT_0 + 2:
// завершился процесс, идентифицируемый h[2], те. описателем (Если Вы передаете FALSE в параметре
fWaitAll, функция WaitForMultipleObjects сканирует массив описателей (начиная с нулевого элемента, и первый же освободившийся объект прерывает ожидание. Это может привести к нежелательным последствиям. Например, Ваш поток ждет завершения трех дочерних процессов при этом Вы передали функции массив сих описателями. Если завершается процесс, описатель которого находится в нулевом элементе массива,
WaitForMultipleObjects возвращает управление. Теперь поток может сделать то, что ему нужно, и вновь вызвать эту функцию, ожидая завершения другого процесса. Если поток передаст те же три описателя, функция немедленно вернет управление, и Вы снова получите значение WAIT_OB
JECT_0. Таким образом, пока Вы не удалите описатели тех объектов, об освобождении которых функция уже сообщила Вам, код будет работать некорректно.
Побочные эффекты успешного ожидания
Успешный вызов
WaitForSingleObject или WaitForMultipleObjects на самом деле меняет состояние некоторых объектов ядра. Под успешным вызовом я имею ввиду тот, при котором функция видит, что объект освободился, и возвращает значение, относительное. Вызов считается неудачным, если возвращается или WAIT_FAILED. В последнем случае состояние каких либо объектов не меняется.
Изменение состояния объекта в результате вызова я называю
побочным эффек
том успешного ожидания (successful wait side effect). Например, поток ждет объект
«событие с автосбросом» (auto reset event object) (об этих объектах я расскажу чуть позже. Когда объект переходит в свободное состояние, функция обнаруживает это и может вернуть вызывающему потоку значение WAIT_OBJECT_0. Однако перед самым возвратом из функции событие переводится в занятое состояние — здесь сказывается побочный эффект успешного ожидания.

212
Ч АС Т Ь I I
НАЧИНАЕМ РАБОТАТЬ
Объекты ядра событие с автосбросом» ведут себя подобным образом, потому что таково одно из правил, определенных Microsoft для объектов этого типа. Другие объекты дают иные побочные эффекты, а некоторые — вообще никаких. К последним относятся объекты ядра процесс и поток, так что поток, ожидающий один из этих объектов, никогда не изменит его состояние. Подробнее о том, как ведут себя объекты ядра, я буду рассказывать при рассмотрении соответствующих объектов.
Чем ценна функция
WaitForMultipleObjects, так это тем, что она выполняет все действия на уровне атомарного доступа. Когда поток обращается к этой функции, она ждет освобождения всех объектов ив случае успеха вызывает в них требуемые побочные эффекты причем все действия выполняются как одна операция.
Возьмем такой пример. Два потока вызывают
WaitForMultipleObjects совершенно одинаково h[2];
h[0] = hAutoResetEvent1;
// изначально занят h[1] = hAutoResetEvent2;
// изначально занят, h, TRUE, На момент вызова
WaitForMultipleObjects эти объекты события заняты, и оба потока переходят в режим ожидания. Но вот освобождается объект
hAutoResetEvent1. Это становится известным обоим потокам, однако ни один из них не пробуждается, так как объект
hAutoResetEvent2 по прежнему занят. Поскольку потоки все еще ждут, никакого побочного эффекта для объекта
hAutoResetEvent1 не возникает.
Наконец освобождается и объект
hAutoResetEvent2. В этот момент один из потоков обнаруживает, что освободились оба объекта, которых он ждал. Его ожидание успешно завершается, оба объекта снова переводятся в занятое состояние, и выполнение потока возобновляется. А что же происходит со вторым потоком Он продолжает ждать и будет делать это, пока вновь не освободятся оба объекта события.
Как я уже упоминал,
WaitForMultipleObjects работает на уровне атомарного доступа, и это очень важно. Когда она проверяет состояние объектов ядра, никто не может
«у нее за спиной изменить состояние одного из этих объектов. Благодаря этому исключаются ситуации со взаимной блокировкой. Только представьте, что получится,
если один из потоков, обнаружив освобождение
hAutoResetEvent1, сбросит его в занятое состояние, а другой поток, узнав об освобождении
hAutoResetEvent2, тоже переведет его в занятое состояние. Оба потока просто зависнут первый будет ждать освобождения объекта, захваченного вторым потоком, а второй — освобождения объекта, захваченного первым.
WaitForMultipleObjects гарантирует, что такого не случится никогда.
Тут возникает интересный вопрос. Если несколько потоков ждет один объект ядра,
какой из них пробудится при освобождении этого объекта Официально Microsoft отвечает на этот вопрос так Алгоритм действует честно Что это за алгоритм, Micro soft не говорит, потому что не хочет связывать себя обязательствами всегда придерживаться именно этого алгоритма. Она утверждает лишь одно если объект ожидается несколькими потоками, то всякий раз, когда этот объект переходит в свободное состояние, каждый из них получает шанс на пробуждение.
Таким образом, приоритет потока не имеет значения поток с самым высоким приоритетом необязательно первым захватит объект. Не получает преимущества и поток, который ждал дольше всех. Есть даже вероятность, что какой то поток сумеет повторно захватить объект. Конечно, это было бы нечестно по отношению к другим потоками алгоритм пытается не допустить этого. Но никаких гарантий нет.

213
Г ЛАВА 9
Синхронизация потоков с использованием объектов ядра
На самом деле этот алгоритм просто использует популярную схему первым вошел первым вышел (FIFO). В принципе, объект захватывается потоком, ждавшим дольше всех. Нов системе могут произойти какие то события, которые повлияют на окончательное решение, и из за этого алгоритм становится менее предсказуемым. Вот почему Microsoft и не хочет говорить, как именно он работает. Одно из таких событий приостановка какого либо потока. Если поток ждет объект и вдруг приостанавливается, система просто забывает, что он ждал этот объект. А причина в том, что нет смысла планировать приостановленный поток. Когда он в конце концов возобновляется, система считает, что он только что начал ждать данный объект.
Учитывайте это при отладке, поскольку в точках прерывания (breakpoints) все потоки внутри отлаживаемого процесса приостанавливаются. Отладка делает алгоритм в высшей степени непредсказуемым из за частых приостановки и возобновления потоков процесса.
События
События — самая примитивная разновидность объектов ядра. Они содержат счетчик числа пользователей (как и все объекты ядра) и две булевы переменные одна сообщает тип данного объекта события, другая — его состояние (свободен или занят).
События просто уведомляют об окончании какой либо операции. Объекты события бывают двух типов со сбросом вручную (manual reset events) и с автосбросом
(auto reset events). Первые позволяют возобновлять выполнение сразу нескольких ждущих потоков, вторые — только одного.
Объекты события обычно используют в том случае, когда какой то поток выполняет инициализацию, а затем сигнализирует другому потоку, что тот может продолжить работу. Инициализирующий поток переводит объект событие в занятое состояние и приступает к своим операциям. Закончив, он сбрасывает событие в свободное состояние. Тогда другой поток, который ждал перехода события в свободное состояние, пробуждается и вновь становится планируемым.
Объект ядра событие создается функцией
CreateEvent:
HANDLE CreateEvent(
PSECURITY_ATTRIBUTES psa,
BOOL fManualReset,
BOOL fInitialState,
PCTSTR В главе 3 мы обсуждали общие концепции, связанные с объектами ядра, — защиту, учет числа пользователей объектов, наследование их описателей и совместное использование объектов за счет присвоения им одинаковых имен. Поскольку все это
Вы теперь знаете, я не буду рассматривать первый и последний параметры данной функции.
Параметр
fManualReset (булева переменная) сообщает системе, хотите Вы создать событие со сбросом вручную (TRUE) или с автосбросом (FALSE). Параметр
fInitialState
определяет начальное состояние события — свободное (TRUE) или занятое (После того как система создает объект событие,
CreateEvent возвращает описатель события, специфичный для конкретного процесса. Потоки из других процессов могут получить доступ к этому объекту 1) вызовом
CreateEvent стем же параметром
pszName; 2) наследованием описателя 3) применением функции DuplicateHandle; и) вызовом
OpenEvent с передачей в параметре pszName имени, совпадающего сука занным в аналогичном параметре функции
CreateEvent. Вот что представляет собой функция
OpenEvent.

214
Ч АС Т Ь I I
НАЧИНАЕМ РАБОТАТЬ OpenEvent(
DWORD fdwAccess,
BOOL fInherit,
PCTSTR Ненужный объект ядра событие следует, как всегда, закрыть вызовом
CloseHandle.
Создав событие, Вы можете напрямую управлять его состоянием. Чтобы перевести его в свободное состояние, Вы вызываете SetEvent(HANDLE А чтобы поменять его на занятое ResetEvent(HANDLE Вот так все просто.
Для событий с автосбросом действует следующее правило. Когда его ожидание потоком успешно завершается, этот объект автоматически сбрасывается в занятое состояние. Отсюда и произошло название таких объектов событий. Для этого объекта обычно не требуется вызывать
ResetEvent, поскольку система сама восстанавливает его состояние. А для событий со сбросом вручную никаких побочных эффектов успешного ожидания не предусмотрено.
Рассмотрим небольшой пример тому, как на практике использовать объекты ядра
«событие» для синхронизации потоков. Начнем с такого кода глобальный описатель события со сбросом вручную (в занятом состоянии g_hEvent;
int WINAPI WinMain(...) {
// создаем объект "событие со сбросом вручную" (в занятом состоянии = CreateEvent(NULL, TRUE, FALSE, NULL);
// порождаем три новых потока hThread[3];
DWORD dwThreadID;
hThread[0] = _beginthreadex(NULL, 0, WordCount, NULL, 0, &dwThreadID);
hThread[1] = _beginthreadex(NULL, 0, SpellCheck, NULL, 0, &dwThreadID);
hThread[2] = _beginthreadex(NULL, 0, GrammarCheck, NULL, 0, &dwThreadID);
OpenFileAndReadContentsIntoMemory(...);
// разрешаем всем трем потокам обращаться к памяти WINAPI WordCount(PVOID pvParam) {
// ждем, когда в память будут загружены данные из файла, INFINITE);
// обращаемся к блоку памяти WINAPI SpellCheck(PVOID pvParam) {
1   ...   20   21   22   23   24   25   26   27   ...   68


База данных защищена авторским правом ©nethash.ru 2019
обратиться к администрации

войти | регистрация
    Главная страница


загрузить материал