Н. А. Литвиненко



Pdf просмотр
страница12/15
Дата28.11.2016
Размер8.29 Mb.
Просмотров3136
Скачиваний1
1   ...   7   8   9   10   11   12   13   14   15
Глава 6

Процессы и потоки


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

Любое приложение, запущенное на выполнение, представляет собой процесс. Каж- дому процессу при загрузке выделяются ресурсы операционной системы:

объект ядра, представляющий собой небольшой блок памяти, через который операционная система управляет выполнением процесса;

адресное пространство, содержащее код и данные.
Приложение может создать новый процесс, проще говоря, может запустить другое при- ложение. В любой момент вновь созданный процесс может быть при необходимости уничтожен. Для создания нового процесса используется функция
CreateProcess()
:
BOOL WINAPI CreateProcessW(
LPCWSTR lpApplicationName,
//имя приложения
LPWSTR lpCommandLine,
//командная строка
LPSECURITY_ATTRIBUTES lpProcessAttributes, //атрибуты доступа процесса
LPSECURITY_ATTRIBUTES lpThreadAttributes, //и потока
BOOL bInheritHandles,
//наследование дескрипторов
DWORD dwCreationFlags,
//флаги
LPVOID lpEnvironment,
//параметры среды
LPCWSTR lpCurrentDirectory,
//текущая папка процесса
LPSTARTUPINFO lpStartupInfo,
//структура стартовых полей
LPPROCESS_INFORMATION lpProcessInformation //возвращаемые значения
);

Глава 6

212
Функция имеет 10 полей, однако многие из них можно задавать по умолчанию (па- раметр
NULL
).

Если вместо имени программы lpApplicationName задать
NULL
, под именем программы будет пониматься первое имя, стоящее в командной строке lpCommandLine
. Этот вариант предпочтительнее, поскольку, во-первых, к имени приложения автоматически добавляется расширение "ехе", во-вторых, прило- жение ищется по стандартной схеме, аналогично поиску dll-файлов. Если же имя приложения задать первым параметром, то обязательно нужно указать пол- ное имя с расширением, и если приложения с таким именем не существует, то работа функции закончится неудачей.
П
Р И МЕ Ч А Н И Е

Unicode- версия функции
CreateProcess()
завершится неудачей, если
CommandLine

строка константного типа.

Если параметры lpProcessAttributes и lpThreadAttributes установлены в
NULL
, используются текущие права доступа. Обычно эти параметры применяются при создании серверных приложений, однако можно установить поле структуры
SECURITY_ATTRIBUTES
bInheritHandle равным
TRUE для определения дескрип- тора как наследуемого. struct SECURITY_ATTRIBUTES {
DWORD nLength;
//размер структуры
LPVOID lpSecurityDescriptor; //указатель описателя защиты
BOOL bInheritHandle;};
//признак наследования
Все дескрипторы объектов ядра, порождаемые с таким параметром, могут насле- доваться порожденными процессами, однако это не означает, что они передаются автоматически. На самом деле порождаемый процесс "ничего не знает" о насле- дуемых дескрипторах, поэтому их нужно как-то передать в порождаемый процесс, например, в параметре командной строки или через переменную окружения.

Обычно процессы создают с нулевым значением параметра dwCreationFlags
, что означает "нормальный" режим выполнения процесса, но при задании других значений флага можно, например, запустить процесс в отладочном режиме или же задать класс приоритета процесса. Справку о допустимых флагах можно по- лучить в справочной системе MSDN (Microsoft Developer Network).

Параметр lpEnvironment
— указатель на буфер с параметрами среды; если этот параметр равен
NULL
, порождаемый процесс наследует среду окружения роди- тельского процесса.

Если параметр lpCurrentDirectory установлен в
NULL
, то текущая папка роди- тельского процесса будет унаследована порождаемым процессом.

Параметр lpStartupInfo
— указатель на структуру
STARTUPINFO
, поля которой определяют режим открытия нового процесса: struct STARTUPINFO {
DWORD cb;
//размер структуры
LPSTR lpReserved;
//NULL

Процессы и потоки

213
LPSTR lpDesktop;
//имя "рабочего стола"
LPSTR lpTitle;
//заголовок консоли
DWORD dwX;
//левый верхний угол
DWORD dwY;
//нового окна
DWORD dwXSize;
//ширина
DWORD dwYSize;
//и высота нового консольного окна
DWORD dwXCountChars; //размер буфера
DWORD dwYCountChars; //консоли
DWORD dwFillAttribute; //цвет текста (в консольном приложении)
DWORD dwFlags;
//флаг определяет разрешенные поля
WORD wShowWindow;
//способ отображения окна
WORD cbReserved2;
//NULL
LPBYTE lpReserved2;
//NULL
HANDLE hStdInput;
//дескрипторы стандартных
HANDLE hStdOutput;
//потоков ввода/вывода
HANDLE hStdError;
//потока ошибок
};
Поле dwFlags обычно устанавливают в
STARTF_USESHOWWINDOW
, что позволяет задать способ отображения окна. Значение
SW_SHOWNORMAL
в поле wShowWindow позволяет Windows самостоятельно определить размер и положение открывае- мого окна, а
SW_SHOWMINIMIZED
означает, что приложение открывается в "свер- нутом" виде.

И, наконец, параметр lpProcessInformation
— указатель на структуру
PROCESS_INFORMATION
, в которой возвращается значение дескриптора и иденти- фикатора порождаемого процесса и потока: struct PROCESS_INFORMATION {
HANDLE hProcess; //дескриптор нового процесса
HANDLE hThread;
//дескриптор главного потока
DWORD dwProcessId; //идентификатор нового процесса
DWORD dwThreadId; //идентификатор главного потока
};
Несмотря на то, что созданный процесс абсолютно независим от родителя, роди- тельский процесс может принудительно завершить его в любой момент функцией
TerminateProcess()
:
BOOL WINAPI TerminateProcess(HANDLE hProcess, UINT fuExitCode);
В качестве первого параметра этой функции используется дескриптор процесса, который возвращается в поле hProcess структуры
PROCESS_INFORMATION
. Второй параметр fuExitCode
— код возврата.
Обе функции возвращают ненулевое значение при успешном завершении.
Процесс может быть завершен и "изнутри" вызовом функции
ExitProcess()
:
VOID WINAPI ExitProcess(UINT fuExitCode);

Глава 6

214
Рассмотрим простейший пример создания процесса (листинг 6.1). Приложение имеет два пункта меню: Открыть блокнот и Закрыть блокнот. Мы запустим стандартное приложение notepad в виде процесса, а затем выгрузим его из памяти.
Чтобы показать возможность передачи параметров создаваемому процессу, пере- дадим ему в командной строке имя файла для открытия.
Листинг 6.1. Создание процесса

TCHAR CommandLine[256] = _T("notepad ReadMe.txt");
LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lPa- ram)
{ static STARTUPINFO tin; static PROCESS_INFORMATION pInfo; static DWORD exitCode; switch (message)
{ case WM_CREATE: tin.cb = sizeof(STARTUPINFO); tin.dwFlags = STARTF_USESHOWWINDOW; tin.wShowWindow = SW_SHOWNORMAL; break; case WM_COMMAND: switch (LOWORD(wParam))
{ case ID_FILE_OPEN:
GetExitCodeProcess(pInfo.hProcess, &exitCode); if (exitCode != STILL_ACTIVE) CreateProcess(NULL, CommandLine,

NULL, NULL, FALSE, 0, NULL, NULL, &tin, &pInfo); break; case ID_FILE_DELETE:
GetExitCodeProcess(pInfo.hProcess, &exitCode); if (exitCode==STILL_ACTIVE) TerminateProcess(pInfo.hProcess, 0); break; case IDM_EXIT: DestroyWindow(hWnd); break; default: return DefWindowProc(hWnd, message, wParam, lParam);
} break; case WM_DESTROY: PostQuitMessage(0); break; default: return DefWindowProc(hWnd, message, wParam, lParam);
} return 0;
}

Процессы и потоки

215
При описании переменных, необходимых для создания нового процесса, мы объя- вили их статическими: static STARTUPINFO tin; static PROCESS_INFORMATION pInfo;
Поэтому все поля переменных tin
, pInfo имеют начальное нулевое значение, и нам достаточно определить лишь 3 поля структуры
STARTUPINFO
. Что мы и сделаем при обработке сообщения
WM_CREATE
: tin.cb = sizeof(STARTUPINFO); tin.dwFlags = STARTF_USESHOWWINDOW; tin.wShowWindow = SW_SHOWNORMAL;
Так мы установим "нормальный" режим открытия приложения.
Процесс же создадим при обработке пункта меню Открыть блокнот, его иденти- фикатор
ID_FILE_OPEN
:
GetExitCodeProcess(pInfo.hProcess, &exitCode); if (exitCode != STILL_ACTIVE) CreateProcess(NULL, CommandLine, NULL,
NULL, FALSE, 0, NULL, NULL, &tin, &pInfo);
В качестве параметров функции зададим всего три отличных от нуля поля:

командную строку для вызова приложения и передачи ему имени файла в каче- стве параметра
CommandLine
;

указатель на структуру tin
, поля которой мы определили при создании окна;

указатель на структуру pInfo
, где мы получим дескриптор созданного процесса;
Обращение к функции
CreateProcess() заключили в условный оператор if (exitCode != STILL_ACTIVE) . . .
чтобы избежать создания нескольких экземпляров блокнота. Это, конечно, не страшно, однако при создании следующего экземпляра блокнота мы не только те- ряем всю информацию о ранее созданном экземпляре, но и возможность уничто- жения объекта ядра, что приведет к "замусориванию памяти".
Переменную exitCode
— код возврата — получим при обращении к функции
GetExitCodeProcess()
:
BOOL WINAPI GetExitCodeProcess(HANDLE hProcess, LPDWORD lpExitCode);
Эта переменная принимает значение
STILL_ACTIVE (0x103)
только в том случае, если процесс активен, поэтому мы благополучно создадим процесс, если он еще не создан.
П
Р И МЕ Ч А Н И Е

Функция GetExitCodeProcess()
может безопасно обратиться по дескриптору, рав- ному 0 —
именно это и будет происходить, пока процесс еще не создан. Если же про- цесс уничтожен "изнутри",
то дескриптор этого процесса будет актуален в родитель- ском приложении, поскольку объект ядра не будет уничтожен до тех пор, пока его дескриптор не закрыт функцией CloseHandle(), и мы сможем получить код завер- шения дочернего процесса.

Глава 6

216
При удалении блокнота в пункте меню Закрыть блокнот необходимо также про- верить — работает ли дочерний процесс. Чтобы разобраться, как это сделано, не- обходимо представлять механизм создания процесса.
При создании нового процесса операционной системой создаются объекты ядра "процесс" и "поток" и выделяется виртуальное адресное пространство процесса.
У каждого объекта ядра существует счетчик пользователей, и здесь счетчику при- сваивается значение 2. Одну единицу присваивает сам созданный процесс, вторая единица добавляется при передаче дескриптора родительскому процессу. По суще- ствующему соглашению операционная система автоматически удаляет объект ядра, как только счетчик обращений принимает нулевое значение. Однако когда приложение закрылось самостоятельно, счетчик пользователей уменьшается на 1 и объект ядра сохраняется, в нем же хранится код возврата.
Таким образом, операционная система сохраняет объект ядра дочернего процесса до тех пор, пока не будет разорвана его связь с родительским процессом.
П
Р И МЕ Ч А Н И Е

Можно "разорвать" связь с родительским процессом сразу после его создания вызо- вом функции CloseHandle(), которая просто уменьшит счетчик обращений на 1.
CloseHandle(pInfo. hThread);
CloseHandle(pInfo.hProcess);
Теперь при самостоятельном закрытии дочернего процесса объект ядра будет авто- матически уничтожен операционной системой.
Имея дескриптор дочернего процесса, несложно проверить его существование, и, если дочерний процесс работает, мы его уничтожим.
GetExitCodeProcess(pInfo.hProcess, &exitCode); if (exitCode == STILL_ACTIVE) TerminateProcess(pInfo.hProcess, 0);
Теперь можно завершать работу блокнота "естественным образом" изнутри или принудительно из родительского процесса, но в этом случае мы потеряем все запи- санные в блокноте данные.
Создание

потока

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

объект ядра;

стек потока.

Процессы и потоки

217
Обычно потоки создают для повышения производительности вычислительной систе- мы в том случае, когда они используют разные ресурсы, которые могут использо- ваться одновременно. Например, в программе, читающей файл с диска, производя- щей вычисления и выводящей данные на печать, уместно создать три потока, однако здесь возникает проблема их синхронизации. Действительно, если данные с диска еще не прочитаны, то вычислять еще рано. Эту проблему мы обсудим позднее.
Для создания потока используется функция
CreateThread()
:
HANDLE WINAPI CreateThread(
LPSECURITY_ATTRIBUTES lpThreadAttributes, //атрибуты доступа
DWORD dwStackSize,
//размер стека потока
LPTHREAD_START_ROUTINE lpStartAddress,
//функция потока
LPVOID lpParameter,
//параметр функции
DWORD dwCreationFlags,
//состояние потока
LPDWORD lpThreadId);
//идентификатор потока
Функция возвращает дескриптор созданного потока либо
0
в случае ошибки.
Если размер стека указан
0
, по умолчанию создается такой же стек, как и у роди- тельского потока.
П
Р И МЕ Ч А Н И Е

По умолчанию стек устанавливается размером в 1
Мбайт.
Тип потоковой функции
PTHREAD_START_ROUTINE описан в файле включений winbase.h следующим образом: typedef DWORD (WINAPI *PTHREAD_START_ROUTINE)(LPVOID lpThreadParameter);
Поток завершается самостоятельно при выходе из функции потока либо может быть завершен извне функцией
TerminateThread()
:
BOOL WINAPI TerminateThread(HANDLE hThread, DWORD dwExitCode);
Функция принимает дескриптор созданного потока hThread и код завершения dwExitCode
, возвращает
TRUE
при успешном завершении.
Для досрочного завершения работы поток может вызвать функцию
ExitThread()
:
VOID WINAPI ExitThread(DWORD dwExitCode);
В примере (листинг 6.2) создадим поток, который присвоит значение указателю на текстовую строку и завершит работу.
Листинг 6.2. Создание потока

TCHAR *pStr;
DWORD WINAPI MyThread(LPVOID param)
{ pStr = (TCHAR*)param; return 0;
}

Глава 6

218
LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
PAINTSTRUCT ps;
HDC hdc;
TCHAR *str = _T("Работал поток!!!"); switch (message)
{ case WM_CREATE:
CreateThread(NULL, 0, MyThread, str, 0, NULL); break; case WM_COMMAND: switch (LOWORD(wParam))
{ case IDM_EXIT: DestroyWindow(hWnd); break; default: return DefWindowProc(hWnd, message, wParam, lParam);
} break; case WM_PAINT: hdc = BeginPaint(hWnd, &ps);
TextOut(hdc, 0, 0, pStr, _tcslen(pStr));
EndPaint(hWnd, &ps); break; case WM_DESTROY: PostQuitMessage(0); break; default: return DefWindowProc(hWnd, message, wParam, lParam);
} return 0;
}
При создании окна в сообщении
WM_CREATE
создадим поток:
CreateThread(NULL, 0, MyThread, str, 0, NULL);
П
Р И МЕ Ч А Н И Е

Идентификатор потока нам не нужен, и мы вместо него указали NULL
Потоковая функция
MyThread()
получила указатель на строку и присвоила гло- бальной переменной значение: pStr = (TCHAR*)param;
Здесь требуется явное преобразование типа.
В сообщении
WM_PAINT
выведем эту строку в окно:
TextOut(hdc, 0, 0, pStr, _tcslen(pStr));
Поскольку функция потока очень короткая, то значение указателя pStr присвоится до того, как он нам понадобится. На рис. 6.1 показан результат работы программы.

Процессы и потоки

219

Рис.
6.1.
Программа с двумя потоками
Однако чаще всего приходится прилагать определенные усилия, чтобы добиться правильной синхронизации работы потоков.
Функции С++ для создания и завершения потока

Стандартные API-функции операционной системы Windows
CreateThread()
и
ExitThread()
, которые мы использовали для создания и завершения потока, разра- батывались еще для Windows 3.1 и имеют некоторые проблемы, связанные с "утеч- кой" памяти, а также с обработкой статических переменных библиотечными функ- циями. Например, несколько потоков могут одновременно изменять внутреннюю статическую переменную функции strtok()
, в которой хранится указатель на те- кущую строку.
Для решения подобных коллизий была разработана новая многопоточная библио- тека. Прототипы новых функций, предназначенных для создания и завершения по- тока, размещены в файле включений process.h
Функция
_beginthreadex() используется для создания потока: unsigned int __cdecl _beginthreadex(void *secAttr, unsigned stackSize, unsigned (__stdcall *threadFunc) (void *), void *param, unsigned flags, unsigned *ThreadId);
Все параметры функции имеют то же значение, что и у функции
CreateThread()
, но типы данных приведены в синтаксисе С++. Разработчики библиотеки решили сохранить верность стандартным типам С++. Но, хотя битовое представление параметров полностью совпадает, компилятор потребует явного преобразова- ния типов. Так, возвращаемое значение функции имеет тип unsigned long
,
и его можно использовать в качестве дескриптора, тем не менее, требуется явное пре- образование.
Потоковая функция здесь должна соответствовать прототипу: unsigned __stdcall threadFunc(void* param);
Для завершения потока используется функция
_endthreadex()
,
ее прототип: void __cdecl _endthreadex(unsigned ExitCode);
Обе эти функции требуют многопоточную библиотеку времени выполнения, кото- рая, начиная с Visual Studio 2005, используется по умолчанию.

Глава 6

220
Для иллюстрации работы функции создания потока
_beginthreadex() добавим файл включений process.h к нашему первому многопоточному приложению (см. листинг 6.2). Произведем следующие замены:
CreateThread(NULL,0, MyThread, str, 0, NULL);
_beginthreadex(NULL, 0, MyThread, str, 0, NULL);
DWORD WINAPI MyThread(LPVOID pa- ram)
{
. . .
} unsigned __stdcall MyThread(void* param)
{
. . .
}
После компиляции приложение будет работать как и ранее, но использует уже но- вую библиотеку функций.
П
Р И МЕ Ч А Н И Е

На самом деле функции _beginthreadex()
и _endthreadex()
являются "обертка- ми" для традиционных функций CreateThread()
и ExitThread(). Они предвари- тельно выделяют память под статические переменные создаваемого потока и очи- щают ее при завершении этого потока.
Измерение времени работы потока

Измерение времени выполнения некоторого участка кода представляется не такой уж простой задачей, поскольку квант времени, выделенный потоку, может завер- шиться в любой момент. Поэтому, если мы определим два момента времени и най- дем их разность, то не можем быть уверены, что это и есть чистое время выполне- ния кода потока. Между этими моментами поток мог неоднократно терять управление. К счастью, имеется функция
GetThreadTimes()
, которая позволяет оп- ределить, сколько времени поток затратил на выполнение операций. Ее прототип размещен в файле включений process.h
BOOL WINAPI GetThreadTimes(HANDLE hThread, LPFILETIME lpCreationTime,
LPFILETIME lpExitTime, LPFILETIME lpKernelTime, LPFILETIME lpUserTime);
Функция принимает дескриптор потока и 4 указателя на переменные, возвращаю- щие временные показатели потока в единицах по 100 нс (т. е. 10
–7
с):

CreationTime
— время создания потока с 1.01.1601;

ExitTime
— время завершения потока с 1.01.1601;

KernelTime
— время выполнения кода операционной системы;

UserTime
— время выполнения кода приложения.
Поскольку для хранения времени в таком формате 32 бит не хватает, для этих це- лей выделяются две 32-битных переменных структуры
FILETIME
struct FILETIME { DWORD dwLowDateTime;
DWORD dwHighDateTime;};

Процессы и потоки

221
Имеется еще одна функция
GetProcessTimes()
, которая позволит определить, сколько времени все потоки процесса (даже уже завершенные) затратили на вы- полнение задачи. В качестве первого параметра функции используется дескриптор процесса.
BOOL WINAPI GetProcessTimes(HANDLE hProcess, LPFILETIME lpCreationTime,
LPFILETIME lpExitTime, LPFILETIME lpKernelTime, LPFILETIME lpUserTime);
Для примера (листинг 6.3 и рис. 6.2) приведем оконную функцию программы, вы- числяющей время работы потока.
Листинг 6.3. Измерение времени выполнения потока

#include unsigned __stdcall MyThread(void* param)
{ for (int i = 0; i < 10000000; i++); return 0;
}
LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lPa- ram)
{
PAINTSTRUCT ps;
HDC hdc;
HANDLE hThread;
LARGE_INTEGER Create, Exit, kernel, user; static __int64 kernelTime, userTime, totalTime;
TCHAR str[256];
RECT rt; switch (message)
{ case WM_COMMAND: switch (LOWORD(wParam))
{ case ID_THREAD: hThread = (HANDLE)_beginthreadex(NULL,0,MyThread,NULL,0,NULL);
WaitForSingleObject(hThread, INFINITE);
GetThreadTimes(hThread, (FILETIME *)&Create.u,
(FILETIME *)&Exit.u, (FILETIME *)&kernel.u, (FILETIME *)&user.u);
CloseHandle(hThread); kernelTime = kernel.QuadPart; userTime = user.QuadPart; totalTime = Exit.QuadPart - Create.QuadPart;
InvalidateRect(hWnd, NULL, TRUE); break;

Глава 6

222
case IDM_EXIT: DestroyWindow(hWnd); break; default: return DefWindowProc(hWnd, message, wParam, lParam);
} break; case WM_PAINT:
GetClientRect(hWnd, &rt); hdc = BeginPaint(hWnd, &ps);
_stprintf(str, _T("kernelTime = %I64d\nuserTime = %I64d\ntotalTime\
= %I64d"), kernelTime, userTime, totalTime);
DrawText(hdc, str, _tcslen(str), &rt, DT_LEFT);
EndPaint(hWnd, &ps); break; case WM_DESTROY: PostQuitMessage(0); break; default: return DefWindowProc(hWnd, message, wParam, lParam);
} return 0;
}
При обработке пункта меню с идентификатором
ID_THREAD
создаем поток функци- ей
_beginthreadex() и функцией
WaitForSingleObject()
ждем его завершения.
Здесь мы забегаем немного вперед, об этой функции будем говорить при обсужде- нии вопроса синхронизации процессов и потоков, пока лишь скажем, что функция приостановит выполнение текущего потока до завершения потока hThread
После чего получим временные характеристики потока:
GetThreadTimes(hThread, (FILETIME *)&Create.u, (FILETIME *)&Exit.u,

(FILETIME *)&kernel.u, (FILETIME *)&user.u);
Чтобы не заниматься преобразованием пары 32-битных чисел в одно 64-битовое значение, поступим проще — используем объединение
LARGE_INTEGER
, где совме- щена в памяти структура
FILETIME
с одной 64-битной переменной типа
LONGLONG
(эквивалентно новому типу данных
__int64
). Теперь мы можем обращаться с пе- ременной типа
LARGE_INTEGER
, как со структурой
FILETIME
, рассматривая поле u
, или как с 64-разрядным числом
LONGLONG в поле
QuadPart

Каталог: uploads
uploads -> Научно-исследовательская работа Путешествие в мир компьютера Севастьянов Вадим
uploads -> «деревянная игрушка коми пермяцкого народа»
uploads -> Викторина «Я хочу здоровым быть»
uploads -> «чем великобритания интересна для россии?» Великобритания входит в число крупнейших мировых держав
uploads -> Персональные компьютеры, история создания и развития
uploads -> Подросток и компьютерные игры
uploads -> Руководство пользователя 2 Заключение 12
uploads -> Сборник Из опыта проектной деятельности учащихся гимназии №524 в 2012-2013 учебном году Санкт-Петербург 2013


Поделитесь с Вашими друзьями:
1   ...   7   8   9   10   11   12   13   14   15


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

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


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