netlib.narod.ru | < Назад | Оглавление | Далее > |
Давайте рассмотрим следующие правила создания быстродействующего программного обеспечения:
Создавать правила легко; гораздо труднее выяснить, как их применять в реальном мире. Исследование некоторого реального работающего кода всегда является хорошим способом получить пример использования концепций программирования, так что давайте взглянем на некоторые из правил создания высокопроизводительного кода в действии.
Если мы собираемся создавать быстродействующий код, сначала нам следует узнать, что этот код будет делать. Для примера давайте напишем программу, которая будет вычислять 16-разрядную контрольную сумму для файла. Другими словами, программа прибавляет каждый байт указанного файла к 16-разрядной сумме предыдущих байтов. Эта контрольная сумма может использоваться, чтобы удостовериться, что файл не был поврежден при передаче по модему или, что троянский вирус не записал свой код в файл. Мы будем только лишь печатать значение контрольной суммы, и сосредоточимся не на его применении, а на вычислении этого значения с максимально возможной скоростью.
Как мы будем вычислять знчение контрольной суммы для указанного файла? Логичный подход состоит в том, чтобы получить имя файла, открыть указанный файл, прочитать все байты из файла, сложить их и напечатать результат. Большинство этих действий очевидно; единственная хитрая часть заключается в чтении и сложении байтов.
Фактически, мы создадим только один детальный план, поскольку только одна часть программы требует размышлений — та часть, которая читает и суммирует байты. Каков лучший способ сделать это?
Было бы удобно загрузить весь файл в память и затем в единственном цикле сложить все байты. К сожалению, нет никакой гарантии, что выбранный файл поместится в доступную программе память; фактически можно быть уверенным, что многие файлы не поместятся в память. Так что данный подход исключается.
Ну что же, если целый файл не помещается в память, то уж один байт поместится всегда. Если мы будем читать из файла по одному байту и добавлять этот байт к контрольной сумме перед тем, как прочитать следующий, то сократим требования к объему памяти, необходимому для программы и сможем обрабатывать файлы любого размера.
Звучит хотошо, не так ли? Пример 1.1 показывает реализацию этого подхода. В нем используется функция read() языка С для чтения единственного байта, затем прочитанный байт прибавляется к значению контрольной суммы и цикл повторяется снова, пока не будет достигнут конец файла. Код компактен, прост для написания и замечательно выполняется — с одной небольшой помехой:
Он работает медленно.
/* * Программа вычисляет 16-разрядную контрольную сумму * всех байтов указанного файла. Получает байты по одному * через функцию read(), позволяя DOS выполнять буферизацию * всех данных. */ #include <stdio.h> #include <fcntl.h> main(int argc, char *argv[]) { int Handle; unsigned char Byte; unsigned int Checksum; int ReadLength; if (argc != 2) { printf("usage: checksum filename\n"); exit(1); } if ((Handle = open(argv[1], O_RDONLY | O_BINARY)) == -1) { printf("Can't open file: %s\n", argv[1]); exit(1); } /* Инициализация значения контрольной суммы */ Checksum = 0; /* Прибавляем каждый байт к контрольной сумме */ while ((ReadLength = read(Handle, &Byte, sizeof(Byte))) > 0) { Checksum += (unsigned int) Byte; } if (ReadLength == -1) { printf("Error reading file %s\n", argv[1]); exit(1); } /* Печать результата */ printf("The checksum is: %u\n", Checksum); exit(0); }
В таблице 1.1 показано время, необходимое примеру 1.1 для вычисления контрольной суммы файла со словарем синонимов редактора WordPerfect версии 4.2, TH.WP (размером 362 293 байт), на обычном компьютере IBM PC AT с тактовой частотой процессора 10 МГц. Время выполнения приводится для примера 1.1, компилируемого компиляторами Borland и Microsoft, как с оптимизацией, так и без. Все четыре времени примерно одинаковы, и все четыре слишком велики, чтобы считаться приемлемыми. Пример 1.1 требует более двух с половиной минут на вычисление контрольной суммы одного файла!
![]() |
Пример 1.2 и пример 1.3 представляют написанный с использованием С и ассемблера эквивалент примера 1.1, а пример 1.6 и пример 1.7 представляют написанный с использованием С и ассемблера эквивалент примера 1.5. | |
Эти примеры поясняют, что надеяться, будто оптимизирующий компилятор сделает ваши программы быстрыми — это безумие. Пример 1.1 плохо спроектирован и никакая оптимизация компилятора не компенсирует этот недочет. В качестве точки отсчета рассмотрим пример 1.2 и пример 1.3, которые вместе являются эквивалентом примера 1.1, за исключением того, что цикл вычисления контрольной суммы написан целиком на ассемблере. Как видно из таблицы 1.1, реализация на языке ассемблера действительно быстрее, чем любая из версий на С, но увеличение скорости составляет всего 10 процентов и программа все равно выполняется недопустимо медленно.
Таблица 1.1. Время вычисления контрольной суммы файла WordPrefect
Пример |
Borland (no opt) |
Microsoft (no opt) |
Borland (opt) |
Microsoft (opt) |
Ассемблер |
Соотношение, достигнутое оптимизацией |
1 | 166,9 | 166,8 | 167,0 | 165,8 | 155,1 | 1,08 |
4 | 13,5 | 13,6 | 13,5 | 13,5 | 1,01 | |
5 | 4,7 | 5,5 | 3,8 | 3,4 | 2,7 | 2,04 |
Соотношение, достигнутое улучшением проекта |
35,51 | 30,33 | 43,95 | 48,76 | 57,44 | |
Примечание. Время выполнения (в секундах) для примеров из этой главы измерялось, когда скомпилированные примеры обрабатывали файл словаря синонимов из WordPrefect 4.2 TH.WP (размером 362 293 байт). Примеры компилировались для модели памяти small компиляторами Borland и Microsoft с включенной (opt) и выключенной (no opt) оптимизацией. Время измерялось программой TIMER компании Paradigm Systems на клоне IBM PC AT с центральным процессором, работающим на частоте 10 МГц, одним тактом ожидания и жестким диском со временем доступа 28 мс и выключенным кэшированием. |
/* * Программа вычисляет 16-разрядную контрольную сумму * потока байтов из заданного файла. Фрагмент на ассемблере * получает байты по одному через прямой вызов DOS. */ #include <stdio.h> #include <fcntl.h> main(int argc, char *argv[]) { int Handle; unsigned char Byte; unsigned int Checksum; int ReadLength; if (argc != 2) { printf("usage: checksum filename\n"); exit(1); } if ((Handle = open(argv[1], O_RDONLY | O_BINARY)) == -1) { printf("Can't open file: %s\n", argv[1]); exit(1); } if (!ChecksumFile(Handle, &Checksum)) { printf("Error reading file %s\n", argv[1]); exit(1); } /* Печать результата */ printf("The checksum is: %u\n", Checksum); exit(0); }
; Процедура на ассемблере, вычисляющая 16-разрядную контрольную ; сумму открытого файла, дескриптор которого передается как ; параметр. Сохраняет результат в указанной переменной. Возвращает ; 1 при успешном завершении и 0 - при ошибке. ; ; Формат вызова: ; int ChecksumFile(unsigned int Handle, unsigned int *Checksum); ; ; где: ; Handle = дескриптор открытого файла, для которого ; вычисляется контрольная сумма ; Checksum = указатель на переменную типа unsigned int ; в которой будет сохранена контрольная сумма ; ; Структура для параметров: ; Parms struc dw ? ; сохраненный BP dw ? ; адрес возврата Handle dw ? Checksum dw ? Parms ends ; .model small .data TempWord label word TempByte db ? ; здесь сохраняется каждый ; прочитанный DOS байт db 0 ; старший байт TempWord ; всегда равен 0 для ; 16-разрядного сложения ; .code public _ChecksumFile _ChecksumFile proc near push bp mov bp,sp push si ; сохраняем регистровые ; переменные С mov bx,[bp+Handle] ; получаем дескриптор файла sub si,si ; помещаем 0 в аккумулятор ; контрольной суммы mov cx,1 ; запрашиваем один байт ; при каждом вызове read mov dx,offset TempByte ; DX указывает на байт ; в котором DOS должна сохранять ; каждый прочитаный байт ChecksumLoop: mov ah,3fh ; функция DOS для чтения из файла int 21h jc ErrorEnd ; возникла ошибка and ax,ax ; байт прочитан? jz Success ; нет - достигнут конец файла, ; завершаем работу add si,[TempWord] ; добавляем байт к общей ; контрольной сумме jmp ChecksumLoop ErrorEnd: sub ax,ax ; ошибка jmp short Done Success: mov bx,[bp+Checksum] ; указатель на переменную ; для контрольной суммы mov [bx],si ; сохраняем контрольную сумму mov ax,1 ; успешное завершение ; Done: pop si ; восстанавливаем регистровые ; переменные С pop bp ret _ChecksumFile endp end
Урок ясен: оптимизация делает код быстрее, но без надлежащего проекта оптимизация всего лишь создаст быстрый медленный код.
Хорошо, тогда как же мы можем улучшить проект? Прежде, чем заняться улучшением, мы должны понять, что неправильно в текущем проекте.
Почему же пример 1.1 работает так медленно? Говоря кратко: из-за накладных расходов. Библиотека С реализует функцию read() посредством вызова DOS для чтения указанного количества байтов. (Я обнаружил это, просмотрев выполнение кода с помощью отладчика, а вы можете приобрести исходные коды библиотек у Borland и Microsoft.) Это означает, что пример 1.1 (так же как и пример 1.3) вызывает функцию DOS при обработке каждого байта — а функция DOS, особенно данная, влечет большие накладные расходы.
Для начинающих скажу, что функции DOS вызываются через прерывания, а прерывания — это одни из самых медленных команд в процессорах семейства x86. Кроме того, DOS должна выполнить внутренние установки и переход к требуемой функции, на что тратится много тактов процессора. Затем DOS ищет собственные внутренние буферы, чтобы проверить, прочитан ли уже требуемый байт, и, если нет, то читает его с диска, сохраняет байт в указанном месте и выполняет возврат. Все это занимает очень много времени — гораздо больше, чем вся оставшаяся часть главного цикла в примере 1.1. Короче говоря, пример 1.1 тратит почти все время на выполнение функции read(), и большая часть этого времени поглощается где-то в недрах DOS.
Вы можете проверить это сами, просмотрев исполнение кода с помощью отладчика или используя профилировщик кода, но поверьте на слово: вызовы DOS приводят к огромным накладным расходам и именно это вытягивает жизнь из примера 1.1.
Как можно ускорить работу примера 1.1? Должно быть ясно, что нам следует каким-либо образом избежать вызова DOS при обработке каждого байта файла, что означает одновременное чтение нескольких байт, помещение данных в буфер и их постепенную выдачу по одному байту за раз для обработки. Черт возьми, — ведь это описание особенностей потокового ввода/вывода языка С, посредством которого программа на С читает файл по частям, буферизует прочитанные данные внутри и затем, по мере поступления запросов от приложения, выдает эти байты, беря их из памяти, вместо того, чтобы вызывать DOS. Давайте применим потоковый ввод/вывод и посмотрим что получится.
Пример 1.4 аналогичен примеру 1.1, но использует для доступа к файлу, контрольная сумма которого вычисляется, функции fopen() и getc() (вместо open() и read()). Результаты блестяще подтверждают теорию и позволяют утвердить новый проект. Как показано в таблице 1.1, пример 1.4 работает на порядок быстрее, чем даже написанная на ассемблере версия примера 1.1, несмотря на то, что код примера 1.1 и код примера 1.4 выглядят практически одинаково. Случайному читателю read() и getc() могут показаться слегка различными, но полностью взаимозаменяемыми, однако различие в производительности этих двух примеров такое же, как различие производительности обычного PC с процессором, работающим на тактовой частоте 4,77 МГц и компьютером с 386 процессором, работающим на тактовой частоте 16 МГц.
![]() |
Удостоверьтесь, что вы действительно понимаете, что происходит, когда вы вставляете кажущийся безвредным вызов функции в критические по времени части вашего кода. | |
В данном случае это означает понимать как DOS и библиотеки доступа к файлам в C/C++ выполняют свою работу. Другими словами, изучите территорию!
/* * Программа для вычисления 16-разрядной контрольной суммы * потока байт из указанного файла. Получает файлы по одному * через getc(), позволя С выполнять буферизацию данных. */ #include <stdio.h> main(int argc, char *argv[]) { FILE *CheckFile; int Byte; unsigned int Checksum; if ( argc != 2 ) { printf("usage: checksum filename\n"); exit(1); } if ( (CheckFile = fopen(argv[1], "rb")) == NULL ) { printf("Can't open file: %s\n", argv[1]); exit(1); } /* Инициализация контрольной суммы */ Checksum = 0; /* Прибавляем каждый байт к контрольной сумме */ while ( (Byte = getc(CheckFile)) != EOF ) { Checksum += (unsigned int) Byte; } /* Выводим результат */ printf("The checksum is: %u\n", Checksum); exit(0); }
В предыдущем разделе была сказана особенно интересная фраза: критические по времени части вашего кода. Критические по времени части кода — это те части, скорость выполнения кода котороых оказывает существенное влияние на быстродействие вашей программы в целом. Под «существенным» я подразумеваю не ускорение выполнения кода на 100 или 200 процентов, или вообще, каое-либо конкретное число, а скорее уменьшение времени отклика или повышение удобства работы с программой с точки зрения пользователя.
Не тратьте время впустую, оптимизируя код, не являющийся критическим по времени: код установки, код инициализации и т.п. Посвятите ваше время улучшению производительности кода внутри часто используемых циклов и в тех частях программы, которые непосредственно влияют на время отклика. Заметьте, например, что я не трудился, чтобы написать версию программы вычисления контрольной суммы полностью на ассемблере. Примеры 1.2 и 1.6 вызывают ассемблерную процедуру, чтобы выполнить критические по времени операции, но для разбора командной строки, открытия файла, печати и других подобных действий продолжает применяться С.
![]() |
Я предполагаю, что если вы напишете любой из приведенных в этой главе примеров целиком на ассемблере и вручную оптимизируете его, то сможете увеличить производительность на несколько процентов, — но я сомневаюсь, что вы зайдете так далеко, и в результате вы убедитесь, как много времени может занимать внесение таких незначительных улучшений. Позвольте С делать то, что он может делать хорошо, и используйте ассемблер только когда это приводит к заметным результатам. | |
Кроме того, не следует переходить к оптимизации, пока проект программы не будет полностью удовлетворять нас, а этого не произойдет, пока мы не думали о других подходах.
Пример 1.4 хорош, но давайте посмотрим, существует ли другой — возможно менее очевидный — способ достичь того же результата еще быстрее. Для начала подумаем, почему пример 1.4 настолько лучше примера 1.1. Также как и read(), getc() для чтения из файла вызывает DOS; увеличение скорости в примере 1.4 достигается потому, что getc() получает от DOS сразу много байт, а затем управляет этими байтами для нас. Это быстрее, чем чтение по одному байту за раз с использованием read() — но нет причины думать, что это быстрее, чем если бы наша программа сама читала блок данных и управляла им. Проще — да, но не быстрее.
Взгляните: каждый вызов getc() требует поместить параметр в стек, выполнить вызов библиотечной функции С, получить параметр (в коде библиотеки С), просмотреть информацию о требуемом потоке, получить из буфера следующий байт потока и вернуться из вызванного кода. Это требует значительных расходов времени, особенно если сравнить с простым управлением указателем на буфер и перебором данных в буфере внутри одного цикла.
Есть четыре причины, по которым многие программисты не стали бы улучшать пример 1.4:
Я игнорирую первую причину и потому, что вопрос быстродействия снимается, если код достаточно быстр, и потому, что в данный момент приложение не выполняется достаточно быстро — 13 секунд это долго. (Остановитесь и подождите 13 секунд, когда вы заняты интенсивной работой, и вы поймете насколько это долго.)
Вторая причина — признак посредственного программиста. Узнайте, когда вопросы оптимизации имеют значение, и затем выполните оптимизацию.
Третья причина часто ошибочна. Библиотечные функции С не всегда пишутся на ассемблере и не всегда хорошо оптимизированы. (Фактически, они часто написаны для переносимости и не имеют никакого отношения к оптимизации.) Более того, они являются функциями общего назначения и часто могут проигрывать в производительности не слишком хорошо написанному, но хорошо приспособленному к конкретной задаче коду. Взгляните на пример 1.5, который использует внутреннюю буферизацию для единовременной обработки блока байтов. Из таблицы 1.1 видно, что пример 1.5 работает от 2,5 до 4 раз быстрее, чем пример 1.4 (и в 49 раз быстрее, чем пример 1.1!) хотя в нем вообще не используется ассемблер.
![]() |
Конечно, вы можете успешно применять специальный код на С вместо вызова библиотечных функций С — если вы полностью понимаете как работает библиотечная функция С и точно знаете потребности вашего приложения. Иначе вы закончите переписыванием библиотечных функций С на С, что абсолютно бессмысленно. | |
/* * Программа вычисления 16-разрядной контрольной суммы * потока байт из заданного файла. Сама выполняет внутреннюю * буферизацию, вместо того, чтобы возлагать эту работу * на библиотеку С или DOS. */ #include <stdio.h> #include <fcntl.h> #include <alloc.h> /* alloc.h для Borland, malloc.h для Microsoft */ #define BUFFER_SIZE 0x8000 /* Буфер данных объемом 32K */ main(int argc, char *argv[]) { int Handle; unsigned int Checksum; unsigned char *WorkingBuffer, *WorkingPtr; int WorkingLength, LengthCount; if (argc != 2) { printf("usage: checksum filename\n"); exit(1); } if ((Handle = open(argv[1], O_RDONLY | O_BINARY)) == -1) { printf("Can't open file: %s\n", argv[1]); exit(1); } /* Выделяем память для буфера данных */ if ((WorkingBuffer = malloc(BUFFER_SIZE)) == NULL) { printf("Can't get enough memory\n"); exit(1); } /* Инициализируем контрольную сумму */ Checksum = 0; /* Обрабатываем файл блоками по BUFFER_SIZE байт */ do { if ((WorkingLength = read(Handle, WorkingBuffer, BUFFER_SIZE)) == -1) { printf("Error reading file %s\n", argv[1]); exit(1); } /* Вычисляем контрольную сумму блока */ WorkingPtr = WorkingBuffer; LengthCount = WorkingLength; while (LengthCount--) { /* Добавляем каждый байт к контрольной сумме */ Checksum += (unsigned int) *WorkingPtr++; } } while ( WorkingLength ); /* Выводим результат */ printf("The checksum is: %u\n", Checksum); exit(0); }
Это подводит нас к четвертой причине: отказ от реализации внутреннего буфера, подобно примеру 1.5, из-за трудности программирования такого подхода. Действительно, гораздо легче позволить выполнять всю работу библиотечным функциям С, но реализация внутренней буферизации это не все трудности. Ключевым моментом здесь является обработка данных в повторно запускаемых блоках (restartable blocks). Это означает чтение блока данных, обработку данных, пока они не закончатся, приостановку обработки, пока читается следующий блок данных, и продолжение работы, как будто ничего не произошло.
В примере 1.5 реализация повторно запускаемого блока очень проста, поскольку при вычислении контрольной суммы работа ведется одновременно только с одним байтом и программа забывает о нем сразу после его добавления к общей сумме. Пример 1.5 читает блок байтов из файла, вычисляет контрольную сумму байтов в блоке, и получает другой блок, повторяя процесс пока не будет обработан весь файл. В главе 5 мы рассмотрим более сложную реализацию повторно запускаемого блока, реализующую поиск текстовых строк.
Во всяком случае, пример 1.5 не намного сложнее, чем пример 1.4, — и при этом он намного быстрее. Всегда рассматривайте альтернативы; немного размышлений и перепроектирование программы могут принести большие результаты.
Я уже несколько раз говорил, что пока проект окончательно не утвержден, оптимизация бессмысленна. Однако, когда время пришло, оптимизация действительно может дать значительный результат. В таблице 1.1 видно, что оптимизация примера 1.5, выполненная компилятором Microsoft C, дает 60-процентный рост производительности. Более того, версия примера 1.5, написанная с использованием ассемблера, которая привдена в примерах 1.6 и 1.7, работает на 26 процентов быстрее, чем самая быстрая оптимизированная компилятором версия примера 1.5. Это значительные усовершенствования, оправдывающие затраченные усилия — только так можно получить максимум от проекта.
/* * Программа вычисления 16-разрядной контрольной суммы * потока байт из заданного файла. Сама выполняет внутреннюю * буферизацию, вместо того, чтобы возлагать эту работу * на библиотеку С или DOS. Критические по времени участки * кода написаны на оптимизированном ассемблере. */ #include <stdio.h> #include <fcntl.h> #include <alloc.h> /* alloc.h для Borland, malloc.h для Microsoft */ #define BUFFER_SIZE 0x8000 /* Буфер данных объемом 32K */ main(int argc, char *argv[]) { int Handle; unsigned int Checksum; unsigned char *WorkingBuffer; int WorkingLength; if (argc != 2) { printf("usage: checksum filename\n"); exit(1); } if ((Handle = open(argv[1], O_RDONLY | O_BINARY)) == -1) { printf("Can't open file: %s\n", argv[1]); exit(1); } /* Выделяем память для буфера данных */ if ((WorkingBuffer = malloc(BUFFER_SIZE)) == NULL) { printf("Can't get enough memory\n"); exit(1); } /* Инициализируем контрольную сумму */ Checksum = 0; /* Обрабатываем файл блоками по 32 Кбайт */ do { if ((WorkingLength = read(Handle, WorkingBuffer, BUFFER_SIZE)) == -1 ) { printf("Error reading file %s\n", argv[1]); exit(1); } /* Вычисляем контрольную сумму блока, если есть данные */ if (WorkingLength) ChecksumChunk(WorkingBuffer, WorkingLength, &Checksum); } while ( WorkingLength ); /* Выводим результат */ printf("The checksum is: %u\n", Checksum); exit(0); }
; Процедура на ассемблере для вычисления 16-разрядной ; контрольной суммы блока, размером от 1 байта до 64 Кбайт. ; Добавляет вычисленную контрольную сумму к переданной ; контрольной сумме. ; ; Формат вызова: ; void ChecksumChunk(unsigned char *Buffer, ; unsigned int BufferLength, unsigned int *Checksum); ; ; где: ; Buffer = указатель на начало блока байт, контрольная сумма ; которого должна быть вычислена ; BufferLength = количество байт в блоке (0 означает 64K, а не 0) ; Checksum = указатель на переменную типа unsigned int ; в которую будет помещена контрольная сумма ; ; Структура параметров: ; Parms struc dw ? ; сохраненный BP dw ? ; адрес возврата Buffer dw ? BufferLength dw ? Checksum dw ? Parms ends ; .model small .code public _ChecksumChunk _ChecksumChunk proc near push bp mov bp,sp push si ; сохраняем регистровую переменную С ; cld ; LODSB будет увеличивать SI mov si,[bp+Buffer] ; указатель на буфер mov cx,[bp+BufferLength] ; получаем длину буфера mov bx,[bp+Checksum] ; указатель на переменную с ; контрольной суммой mov dx,[bx] ; получаем текущее значение ; контрольной суммы sub ah,ah ; чтобы после LODSB в AX ; было 16-разрядное значение ChecksumLoop: lodsb ; получаем следующий байт add dx,ax ; прибавляем его к контрольной сумме loop ChecksumLoop ; повторяем для всех байтов в блоке mov [bx],dx ; сохраняем новую контрольную сумму ; pop si ; восстанавливаем регистровую ; переменную С pop bp ret _ChecksumChunkendp end
Обратите внимание, что в таблице 1.1 оптимизация дает весьма незначительный прирост производительности, за исключением примера 1.5, проект которого был значительно переработан. Время выполнения других версий в основном зависит от времени, которое тратится DOS или библиотекой С, и оптимизация написанного вами кода почти не приносит результатов. Кроме того, сравните двухкратный рост производительности, полученный в результатае оптимизации, с более чем 50-кратным ростом произволительности, полученным при переработке проекта программы.
Между прочим, даже время выполнения примеров 1.6 и 1.7 зависит от времени доступа к диску в DOS. Если для диска разрешено кэширование и файл, контрольная сумма которого вычисляется, уже находится в кэше, версия на ассемблере работает в три раза быстрее, чем версия на С. Другими словами, внутренняя природа нашего приложения ограничивает прирост быстродействия, которого можно достичь используя ассемблер. В приложениях, которые более интенсивно используют центральный процессор и меньше привязаны к диску, особенно в тех приложениях, где можно эффективно применять команды работы со строками и/или развернутые циклы, код на ассемблере будет более быстрым по сравнению с кодом на С, чем в нашем конкретном случае.
![]() |
Не попадите в зависимость от оптимизирующего компилятора или языка ассемблера — лучший оптимизатор находится у вас между ушей. | |
Все это можно сказать одной фразой: знайте, куда вы идете, изучите территорию и знайте, что имеет значение.
netlib.narod.ru | < Назад | Оглавление | Далее > |