QNX/UNIX: Анатомия параллелизма - Олег Цилюрик 13 стр.


(Очень важно!) Достаточно много библиотечных функций могут сами устанавливать точки отмены. Более того, такие функции могут косвенно вызываться из других функций в программе и тем самым неявно устанавливать точки отмены. Информацию о таких функциях следует искать в справочной man-странице по функции pthread_testcancel(). В результате этого эффекта можно получить отмену потока не в той точке, которую вы считаете безопасной и которую явно отмечаете вызовом pthread_testcancel(), а ранее этой точки - когда будет вызвана одна из таких функций. А это, очевидно, вовсе не то, на что вы рассчитывали!

Если состояние отмены потока, как это описывалось ранее, установлено в PTHREAD_CANCEL_DISABLE, то никакая расстановка точек отмены не имеет эффекта и поток остается неотменяемым.

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

void* function(void* data) {

int state;

pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &state);

// ... здесь выполняется инициализация ...

pthread_setcanceltype(PTHREAD_CANCEL_DEFERRED, NULL);

pthread_setcancelstate(&state, NULL);

while (true) {

struct blockdata *blk = new blockdata;

// ... обработка блока данных blk ...

delete blk;

pthread_testcancel();

}

}

...

pthread_t tid;

...

pthread_create(&tid, NULL, function, NULL);

...

pthread_cancel(tid); // отмена потока

void* res;

pthread_join(tid, &res); // ожидание отмены

if (res != PTHREAD_CANCELED)

cout << "Что-то не так!" << endl;

Наконец, в QNX (но не в POSIX) существует вызов, подобный pthread_cancel(), принудительно отменяющий поток независимо от его установок ("желания"):

int pthread_abort(pthread_t thread);

В отличие от pthread_cancel(), этот вызов принудительно и немедленно отменяет поток. Кроме того, никакие процедуры завершения и деструкторы собственных данных потока не выполняются. Очевидно, что в результате такого "завершения" состояния объектов процесса будут просто неопределенными, поэтому такой вызов крайне опасен. При таком способе отмены в программный код, ожидающий завершения на pthread_join(), в качестве результата завершения возвращается константа (тип void*) PTHREAD_ABORTED (аналогично возвращается константа PTHREAD_CANCELED при выполнении pthread_cancel()).

Но и этих мер безопасности недостаточно на все случаи жизни, поэтому механизм потоков предусматривает еще один уровень (механизм) страховки.

Стек процедур завершения

Для поддержания корректности состояния объектов процесса каждый поток может помещать (добавлять) в стек процедур завершения (thread's cancellation-cleanup stack) функции, которые при завершении (pthread_exit() или return) или отмене (по pthread_cancel()) выполняются в порядке, обратном помещению. Для манипуляции со стеком процедур завершения предоставляются вызовы (оба вызова реализуются макроопределениями, но это не суть важно):

void pthread_cleanup_push(void (routine)(void*), void* arg);

где routine - адрес функции завершения, помещаемой в стек; arg - указатель блока данных, который будет передан routine при ее вызове.

Функции завершения (начиная с вершины стека) вызываются со своими блоками данных в случаях, когда:

• поток завершается, выполняя pthread_exit();

• активизируется действие отмены потока, ранее запрошенное по вызову pthread_cancel();

• выполняется второй (комплементарный к pthread_cleanup_push()) вызов с ненулевым значением аргумента:

void pthread_cleanup_pop(int execute);

Этот вызов выталкивает из стека последнюю помещенную туда pthread_cleanup_push() функцию завершения и, если значение execute ненулевое, выполняет ее.

Вот как может выглядеть в этой технике безопасный (с позиции возможной асинхронной отмены потока) захват мьютекса:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void cleanup(void* arg) { pthread_mutex_unlock(&mutex); }

void* thread_function(void* arg) {

while (true) {

pthread_mutex_lock(&mutex);

pthread_cleanup_push(&cleanup, NULL);

{

// все точки отмены должны быть расставлены в этом блоке!

}

pthread_testcancel();

pthread_cleanup_pop(1);

}

}

"Легковесность" потока

Вот теперь, завершив краткий экскурс использования процессов и потоков, можно вернуться к вопросу, который вскользь уже звучал по ходу рассмотрения: почему и в каком смысле потоки часто называют "легкими процессами" (LWP - lightweight process)?

Выполним ряд тестов по сравнительной оценке временных затрат на создание процесса и потока. Начнем с процесса (файл p2-1.cc):

Затраты на порождение нового процесса

struct mbyte { // мегабайтный блок данных

#pragma pack(1)

uint8_t data[1024 * 1024];

#pragma pack(4)

};

int main(int argc, char *argv[]) {

mbyte *blk = NULL;

if (argc > 1 && atoi(argv[1]) > 0) {

blk = new mbyte[atoi(argv[1])];

}

uint64_t t = ClockCycles();

pid_t pid = fork();

if (pid == -1) perror("fork"), exit(EXIT_FAILURE);

if (pid == 0) exit(EXIT_SUCCESS);

if (pid > 0) {

waitpid(pid, NULL, WEXITED);

t = ClockCycles() - t;

}

if (blk != NULL) delete blk;

cout << "Fork time " << cycle2milisec(t)

<< " msec. [" << t << " cycles]" << endl; exit(EXIT_SUCCESS);

}

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

А теперь оценим временные затраты на создание клона процесса в зависимости от объема программы (мы сознательно использовали клонирование процесса вызовом fork(), а не загрузку spawn*() или exec*(), чтобы исключить из результата время загрузки образа процесса из файла):

# p2-1

fork time: 3.4333 msec. [1835593 cycles]

# p2-1 1

Fork time: 17.0706 msec [9126696 cycles]

# p2-1 2

Fork time: 31.5257 msec. [16855024 cycles]

# p2-1 5

Fork time: 70.7234 msec. [37811848 cycles]

# p2-1 20

Fork time: 264.042 msec. [141168680 cycles]

# p2-1 50

Fork time: 661.312 msec. [353566688 cycles]

# p2-1 100

Fork time: 1169.45 msec. [625241336 cycles]

Наблюдаются, во-первых, достаточно большие временные затраты на создание процесса (к этому мы еще вернемся), а во-вторых, близкая к линейной зависимость времени создания процесса от размера его образа в памяти и вариации этого времени на несколько порядков. Об этом уже говорилось при рассмотрении функции fork(): это следствие необходимости полного копирования образа адресного пространства родительского процесса во вновь создаваемое для дочернего процесса адресное пространство. При этом линейный рост времени копирования от размера образа процесса становится естественным (вот почему для образов таких задач при их построении посредством программы make в высшей степени целесообразно выполнить завершающую команду strip для уменьшения размера итогового образа задачи). Более того, это "высоко затратная" операция копирования, не в пример привычной функции memcpy(). Копирование производится между различными адресными пространствами обращением к средствам системы по принципу: скопировать N байт, начиная с адреса А адресного пространства Р, по адресу, начиная с А (тот же адрес!) адресного пространства С. В большинстве других ОС некоторое смягчение вносит использование техники COW (copy on write), но и этот эффект кажущийся (см. выше подробное обсуждение при описании функции fork()).

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

T = 3000000 + Р * 6000

где Р - размер (в килобайтах) файла образа программы, в которой выполняется fork().

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

Затраты на создание потока

void* threadfunc(void* data) { pthread_exit(NULL); }

int main(int argc, char *argv[]) {

uint64_t t = ClockCycles();

pthread_t tid;

pthread_create(&tid, NULL, threadfunc, NULL);

pthread_join(tid, NULL);

t = ClockCycles() - t;

cout << "Thread time, " << cycle2milisec(t) << " msec. [" << t <<

" cycles]" << endl;

exit(EXIT_SUCCESS);

}

На результаты этого теста (в отличие от предыдущего) уже достаточно существенно влияет приоритет, под которым выполняется задача, поэтому проделаем его с достаточно высоким приоритетом (29):

# nice -n-19 p2-2

Thread time: 0.147139 msec. [78667 cycles]

# nice -n-19 p2-1

Fork time: 2.5366 msec. [1356179 cycles]

Вот так… время порождения нового "пустого" процесса, даже минимального размера (размер исполняемого файла этого процесса чуть больше 4 Кбайт), почти в 20 раз больше затрат на создание потока! А для процессов большого объема эта разница может доходить до 3–4 порядков (см. результаты первого теста).

Далее рассмотрим сравнительную эффективность с другой стороны: будет ли диспетчеризация многочисленных потоков, принадлежащих одному процессу, эффективнее диспетчеризации такого же количества отдельных процессов? Для процессов задача текстуально выглядит так (файл p4-1.cc):

void workproc(int how = 1) {

const int nsingl = 1000, msingl = 30;

for (int j = 0; j < how; j++) // ... имитация вычислений

for (uint64_t i = 0; i < msingl; i++)

for (uint64_t k = 0; k < nsingl; k++)

k = (k + 1) - 1;

}

int main(int argc, char *argv[]) {

int numpar = 1;

if (argc > 1 && atoi(argv[1]) > 0)

numpar = atoi(argv[1]);

_clockperiod clcold;

ClockPeriod(CLOCK_REALTIME, NULL, &clcold, 0);

if (argc > 2 && atoi(argv[2]) > 0) {

_clockperiod clcnew = { atoi(argv[2]) * 1000, 0 };

ClockPeriod(CLOCK_REALTIME, &clcnew, &clcold, 0);

}

timespec interval;

sched_rr_get_interval(0, &interval);

cout << "Rescheduling interval = "

<< (double)interval.tv_nsec / 1000000 << " msec." << endl;

uint64_t t = ClockCycles();

for (int i = 0, i < numpar; i++) {

pid_t pid = fork();

if (pid == -1) perror("fork"), exit(EXIT_FAILURE);

if (pid == 0) {

workproc(1000);

exit(EXIT_SUCCESS);

}

}

for (int i = 0; i < numpar; i++) wait3(NULL, WEXITE0, NULL);

t = ClockCycles() - t;

cout << "Forks scheduling time" << cycle2milisec(t)

<< " msec [" << t << " cycles]" << endl;

ClockPeriod(CLOCK_REALTIME, &clcold, NULL, 0);

exit(EXIT_SUCCESS);

}

Назад Дальше