Перевод: Андрей Чиликин (atchilikine at hotmail.com)
(Оригинал статьи в формате Postscript)
(Оригинал статьи доступен здесь)
Утилита идентификации потоков
Проблема
Я разработал утилиту идентификации потоков (в оригинале Thread Fingerprint Utility - TFU) работая по контракту с Photuris Inc. Она нужна для решения довольно специфичной проблемы - как определить чем занят определённый поток из множества потоков одного процесса?
Проект начался с телефонного разговора, звучавшего на первый взгляд, вполне обычно:
"У нас проблема с одним из потоков, который выполняется с высоким приоритетом."
"ОК, что это за поток?"
В чем именно проблема, стало ясно как только я услышал ответ: "Это поток номер 8 процесса io-net."
Проблема заключалась в том, что io-net является процессом, который запускает кроме нескольких своих потоков еще по одному потоку на каждую загруженную по запросу динамическую библиотеку (DLL). Это осложняет и без того непростую ситуацию потому что можно было бы использовать pidin чтобы проверить, что поток 8 работает с высоким приоритетом 22 и занимает много процессорного времени, но узнать за что конкретно отвечает этот поток не представлялось возможным. Простейшим решением было бы использование printf() для печати ID каждого потока при его старте, но это не сработало бы в общем случае, так как потребовало бы написание отдельной утилиты для сбора этих данных ото всех потоков, запущенных в системе, включая динамически порождаемые потоки, и дальнейшего анализа этой информации когда потребуется.
Требования
Требования к утилите, которые стали понятны из телефонного разговора, были довольно нехитры: заказчики хотели простой иметь простой, минимально затрагивающий поведение программы, способ ассоциировать некую маркировку с каждым потоком, который они могли контролировать в их системе. Этот маркер должен был отображаться вместе с другой информацией выводимой pidin . Мы договорились, что для "маркировки потока" будет достаточно небольшого массива данных, который должен содержать по крайней мере ASCII строку. Позже я добавил еще и 64 байтный массив двоичных данных - я не знаю, использовали ли они его или нет, он был добавлен в качестве резерва для "будущего применения".
Конечно, возможность интегрировать отображение такой информации в pidin было бы очень изящным решением, но это было невозможно, так как у меня в то время не было доступа к исходным кодам pidin (сейчас этот исходный код доступен на CVS сервере QSSL cvs.qnx.com). К тому же, мы решили, что модифицировать pidin не стоит. Так как он принадлежит QSSL, любые последующие изменения в его исходных кодах, внесенные QSSL, могли бы сделать наш код неработоспособным. Конечно, имея разрешение QSSL, это можно было бы решить с помощью patch, но мы не хотели связывать себя поддержкой нашего кода в дальнейшем. Поэтому мы решили что лучшим для нас будет создание отдельной утилиты (tfp), которая будет печатать ID процесса, ID потока и "маркировку потока" для каждого потока в системе. Потоки, у которых маркировка отсутствует, печататься не должны.
Таким образом, требования для решения проблемы идентификации потоков можно было сформулировать следующим образом:
каждый поток должен иметь возможность "идентифицировать" сам себя;
поток должен иметь возможность изменить свое "удостоверение личности" в любое время;
должна быть внешняя утилита командной строки, которая могла бы печатать строку идентификации каждого потока вместе с шестнадцатеричным дампом блока данных.
Так как требования сделать строку идентификации и блок двоичных данных доступными для других утилит не было, мы ограничились одной утилитой командной строки.
Дизайн
Главной сложностью дизайна было принятие решения о том, где именно хранить идентифицирующую информацию. Очевидно, что она должна была храниться для каждого потока индивидуально, что в свою очередь означало или изощренное применение глобальных переменных или использование существующей в Neutrino поддержки потоковых областей данных.
Я исключил применение глобальных переменных в самом начале работы над дизайном, в основном из-за того, что глобальные переменные потребуют защиты с помощью мьютекса при обращении к данным (глобальные данные и потоки обычно не уживаются вместе). На уровне программирования потока это было бы легко: захватить мьютекс, обновить данные, освободить мьютекс. Тем не менее, на уровне программирования утилиты чтения это было бы гораздо сложнее - прежде всего, нам бы был нужен механизм отыскания области глобальных данных (так как мы предполагаем, что утилита не будет иметь возможности использовать map файл для каждого процесса при запуске) и мы бы должны были каким-то образом использовать внутренние мьютексы потоков для синхронизации.
Как оказалось, синхронизация с использованием внутреннего мьютекса потоками не была значительным препятствием; при надлежащем кодировании я мог бы сделать так, что попытка синхронизации безопасно завершалась, если шёл процесс изменения данных. Главная проблема заключается в нахождении области глобальных данных. Другая проблема, о которой я не подумал в то время, но которая в последствии была решена выбранной реализацией без дополнительных затрат, это завершение потоков. Когда поток завершается, нам хотелось, чтобы информация идентификации потока автоматически удалялась. Кроме установки обработки atexit() для каждого потока не было простого и понятного пути как это сделать. Как вы помните, мы хотели добиться результата с минимальным вмешательством, так что установка дополнительного обработчика являлась еще одним аргументов против такого решения.
В итоге я решил остановиться на подходе с применением реализованных в Neutrino потоковых областей данных по стандарту POSIX. Если вы исследуете заголовочный файл <sys/storage.h>, вы обнаружите следующую структуру:
/*
* Локальные данные потока.
* Эти данные расположены вверху
* стека каждого потока.
*/
struct _thread_local_storage {
void (*exitfunc)(void *);
void *arg;
int *errptr;
int errval;
unsigned flags;
int pid;
int tid;
unsigned owner;
void *stackaddr;
unsigned reserved1;
unsigned numkeys;
void **keydata; // Индексировано с использованием pthread_key_t
void *cleanup;
void *fpuemu_data;
void *reserved2 [2];
};
Как только я увидел "вверху стека каждого потока" я понял что я при деле. Я считал, что должна быть возможность найти верх стека любого потока, и действительно, беглый анализ заголовочного файла описывающего структуру /proc дал мне всю информацию, которая была нужна.
Так что следующим вопросом был "Где же и как я припрячу свои данные"? Глядя на поля приведенной выше структуры, становится ясно, что почти все из них не подходят для этого. Также я не собирался использовать поле reserved2 - хотя оно и зарезервировано, я не думал, что оно зарезервировано специально для меня! :-)
Поэтому единственным полем, которое давало надежду на использование, было поле keydata. Я обратил внимание на POSIX функции pthread_key_create(), pthread_getspecific() и pthread_setspecific(). К счастью, их реализация стала довольно понятна после быстрого просмотра заголовочного файла <pthread.h>:
typedef int pthread_key_t;
...
#define pthread_getspecific(key) ((key) < __tls()->numkeys ? __tls()->keydata[key] : 0)
Вспомните, что в определении структуры struct _thread_local_storage для keydata есть комментарий "индексировано с помощью pthread_key_t". Это наводит на мысль, что keydata это массив, который предназначен, исходя из его определения, для хранения указателей типа void *. Из документации по pthread_getspecific() и pthread_setspecific() мы знаем, что область данных, которая может быть ассоциирована с потоком, тоже является void *. Несколько маленьких экспериментов подтвердили, что простой проход по массиву keydata позволяет найти все значения привязанных к потоку данных. Отсюда понятно, что numkeys определяет размер массива. И хотя это неправильно - получать доступ к членам массива keydata напрямую вместо использования POSIX функций для работы с ключами, у нас не было другого выбора, когда речь шла об утилите командной строки - только прямой доступ позволяет получить данные из другого процесса.
Вооружившись этой информацией, было довольно просто включить нечто в локальные данные потока, что решало первую часть требований. Лучшей деталью такой реализации было то, что это был POSIX способ решения проблемы. Как-никак функции POSIX для работы с ключами были сделаны специально для операций такого характера: хранение (и получение) чего-либо локально для любого потока. Используя функции, предоставляемые POSIX, мы не нарушали никаких правил кодирования и сохраняли переносимость настолько, насколько это возможно.
Оставался один вопрос, который должен был быть решен - как именно мы будем отличать наши ключевые данные от других ключей, хранимых для потока. Для этого подходило использование простой сигнатуры, и мы взяли для нее строку "pHoTuRiS", которая определена в комментариях как "нечто уникальное и 'маловероятное'". Конечно любая выбранная наугад строка (или двоичное значение) тоже подошли бы, но я отдаю предпочтение текстовым строкам.
Код
В этом разделе мы обсудим главные части кода. Сам код разбит на две части: первая часть это заголовочные файлы и библиотека, которая используется потоками, вторая - сама утилита командной строки. Весть исходный код доступен на прилагаемом к книге CD-ROM.
Библиотека
Библиотека состоит из двух файлов:
tfplib.c
tfplib.h
Функционирование
Файл tfplib.c содержит API библиотеки для идентификации потоков, который может вызываться любым потоком. Главной функцией является tfp_mark(), она используется для привязки маркера к вызывающему ее потоку. Вспомогательные функции tfp_read_raw_block() и tfp_write_raw_block() используются для чтения и записи 64-байтного блока двоичных данных. Никакого особого смысла этому блоку не предается, приложение может использовать его по своему усмотрению. Я предусмотрел его для хранения переменных состояния или прогресса выполнения. Как использовался этот блок в Photuris (если использовался вообще), я не знаю. Но он есть и много места не занимает.
Библиотека демонстрирует применение определенных в POSIX ключевых данных потоков.
Следующие функции являются определяющими для библиотеки:
pthread_key_create(), pthread_getspecific(), и pthread_setspecific()
Вы можете найти описание этих функций в C Library Reference, которое поставляется с вашей системой Neutrino.
В нашей реализации эти функции используются потоком для создания и регистрации (а также перерегистрации так часто, как это необходимо) его маркера. (Обычно эти функции используются потоками когда необходим доступ к области данных, локальной для потока. Для большей информации смотрите описание в документации.)
Прежде чем мы углубимся в детали кода, позвольте мне продемонстрировать, как просто использовать эту библиотеку:
#include <stdio.h>
#include <stdlib.h>
#include "tfplib.h"
int
main (int argc, char **argv)
{
printf ("Hello, world\n");
tfp_mark ("Main thread sleeping");
sleep (60);
tfp_mark ("Main thread about to exit");
sleep (10);
exit (EXIT_SUCCESS);
}
Приведенный выше пример представляет собой незначительную модификацию стандартной программы "Hello, world". Главный поток этой программы использует TFP библиотеку для того, чтобы маркировать себя строкой "Главный поток спит" и засыпает на 60 секунд. Потом поток изменяет маркер на "Главный поток готовится к завершению" и ждет еще 10 секунд прежде чем завершиться.
Запуск утилиты tfp (которую мы обсудим позже) три раза выдает следующий результат:
# tfp
PID 17932361 ".//a.out"
TID 1 2003 10 09 16:21:46 Главный поток спит
PID 18878540 "/source//photuris/parse/tfp/x86/o/tfp"
TID 1 2003 10 09 16:22:44 Главный поток TFP
# tfp
PID 17932361 ".//a.out"
TID 1 2003 10 09 16:22:46 Главный поток готовится к завершению
PID 18911308 "/source//photuris/parse/tfp/x86/o/tfp"
TID 1 2003 10 09 16:22:46 Главный поток TFP
# tfp
PID 19071049 "/source//photuris/parse/tfp/x86/o/tfp"
TID 1 2003 10 09 16:25:08 Главный поток TFP
Когда я запустил ее первый раз, тестовая программа, приведенная выше, как раз заснула на 60 секунд, так что утилита показала, что процесс имеет один поток (который является главным для процесса), и этот поток маркирован строкой "Главный поток спит". Заметьте, что второй процесс - это сама утилита tfp - ее главный поток промаркирован строкой "Главный поток TFP".
Минутой позже я запустил утилиту снова, и на этот раз тестовая программа изменила свой маркер на "Главный поток готовится к завершению".
В завершении теста, третий запуск tfp (несколькими минутами позже) выдал результат только для нее самой.
Пошаговый анализ кода
Давайте теперь взглянем на основные части кода библиотеки TFP.
Начнем мы с заголовочного файла tfplib.h. Каждая программа, в которой будет использоваться библиотека идентификации, должна включать tfplib.h.
В этом файле объявлены три константы:
- TFP_SIGNATURE_LENGTH
- Длина сигнатуры в байтах, которую мы используем для идентификации нашего ключа.
- TFP_SIGNATURE_STRING
- Уникальная строка, используемая в качестве сигнатуры.
- TFP_RAW_BLOCK_LENGTH
- Длина двоичного блока данных в байтах.
Другой важной частью заголовочного файла является определение структуры маркера:
typedef struct tfp_signature_block_s {
char signature [TFP_SIGNATURE_LENGTH];
time_t t;
char *string;
int nstring;
unsigned char raw_block [TFP_RAW_BLOCK_LENGTH];
} tfp_signature_block_t;
Структура включает в себя следующие члены:
- signature
- Содержит уникальную сигнатуру. С ее помощью мы определяем что именно этот ключ в области данных потока представляет собой наш маркер.
- t
- Время последнего обновления маркера, выводится утилитой командной строки.
- string и nstring
- Маркер потока и длина маркера.
- raw_block
- Блок двоичных данных; используйте tfp_read_raw_block() и tfp_write_raw_block() чтобы получить к нему доступ из потока.
Поток, который хочет быть идентифицирован утилитой командной строки, вызывает функцию:
int tfp_mark (char *string);
и передает строку-маркер, которую поток будет использовать как свой идентификатор. Возвращаемое значение или EOK или errno.
Сама функция tfp_mark() определена в файле tfplib.c и выглядит вот так:
static pthread_once_t tfp_oc = PTHREAD_ONCE_INIT;
static pthread_key_t tfp_key;
static void tfp_init (void);
int
tfp_mark (char *string)
{
tfp_signature_block_t *tfpblk;
// 1) обеспечить создание ключа (только один раз)
pthread_once (&tfp_oc, tfp_init);
// 2) проверить существование ключа
if ((tfpblk = pthread_getspecific (tfp_key)) == NULL) {
// 3) если нет, выделить под него память и заполнить ее
tfpblk = calloc (1, sizeof (*tfpblk));
if (tfpblk == NULL) {
return (errno);
}
memcpy (tfpblk, TFP_SIGNATURE_STRING, TFP_SIGNATURE_LENGTH);
// 4) связать наши данные с ключом
pthread_setspecific (tfp_key, tfpblk);
}
// 5) если строка уже существует, освободить память, которую она
// занимает, так как мы собираемся ее переписать
if (tfpblk -> string) {
free (tfpblk -> string);
}
// 6) распределить память под новую строку
tfpblk -> string = strdup (string);
// 7) запомнить длину строки
tfpblk -> nstring = strlen (tfpblk -> string) + 1;
// 8) запомнить текущее время
time (&tfpblk -> t);
return (0);
}
- Мы используем pthread_once() чтобы убедиться, что ключ для процесса создается только один раз. Работа pthread_once() заключается как раз в том, чтобы вызвать передаваемую ей функцию (tfp_init()) однократно, и в случае, когда несколько потоков одновременно пытаются получить доступ к этой функции, заблокировать их на время своего исполнения. Такое поведение идеально для использования в библиотечных вызовах и применение нами этой функции в данном случае типично. pthread_once() вызывает другую функцию - tfp_init(), которая в свою очередь вызывает pthread_key_create() (смотрите пример кода ниже, после этого объяснения).
- Вызывая pthread_getspecific() мы проверяем существование ключа, связанного с нашим потоком. Если мы вызываем нашу tfp_mark() повторно для смены маркировки потока, то мы уже имеем ключ, привязанный к потоку. Если это первый вызов, то функция вернет NULL.
- При первом вызове нам нужно создать пустую область данных используя calloc(). После ее создания запишем в эту область нашу сигнатуру.
- После этого мы связываем созданную область данных с нашим ключом вызовом pthread_setspecific().
- Этот шаг выполняется независимо от того, была ли область данных только что создана или мы она уже существовала. У нас есть две возможности оптимизации на этом шаге (мы рассмотрим их ниже, после анализа кода).
- После того как мы освободили старую строку с помощью free(), мы распределяем новую используя strdup(). Заметьте, что иногда даже к законченному коду мы относимся немножко неряшливо и не проверяем возвращаемых значений. Кто-то может доказывать что это непростительно в данном случае, потому что если крошечный вызов strdup() даст сбой, ваш процесс (или системе) не поздоровиться. Да, конечно, кто-то может вызвать ее с длинной строкой (или строкой без завершающего нуля), но в данном случае она рассчитана в первую очередь на строковые константы типа "рабочий поток 1".
- Мы вычисляем и запоминаем длину строки. На данный момент это выглядит глупо, как-никак строки в языке C завершаются нулем, но вы найдете этому разумное объяснение, когда мы будем рассматривать утилиту командной строки. (Я должен признаться, что поле длины было добавлено на стадии написания утилиты командной строки. Некоторые вещи гораздо проще делать если у вы владеете всем исходным кодом.)
- И, наконец, просто для удобства, мы запоминаем текущее время вызывая time(). Это стоит нам четыре байта данных и ничтожное количество времени CPU по сравнению с другими операциями. Эта операция не была порождена требованиями реализации, а была добавлена интуитивно - я полагал что хранение "свежести" данных было хорошей идеей. Конечно, некоторые потоки, единожды записав свою информацию, никогда не меняли бы ее, но в некоторых случаях потоки могли бы использовать строковое поле для отчета о прогрессе работы. Именно для таких случаев и предусмотрено поле t - что бы вы могли проверить, когда было выполнено обновление данных потоком. Это же поле t обновляется каждый раз при вызове tfp_write_raw_block().
Оптимизация
Как указано выше, шаг 5 может быть оптимизирован следующим образом:
Во-первых, в реальности нам не нужно тестировать строку на существование каждый раз. Единственный случай, когда строка может не существовать - это сразу после создания ключа, то есть если мы не создали ключ только что, мы знаем, что строка существует.
Во-вторых, мы всегда освобождаем и перераспределяем память для строки. Вместо этого мы бы могли проверять длину строки и, если новая строка такой же длины или короче, мы бы просто могли перезаписать строку без дополнительных расходов на вызовы free() и strdup(). Просто такая возможность не представлялась чем-то необходим на время написания, но вы можете принять ее во внимание, если ваш код модифицирует строку довольно часто - ведь вызовы free() и strdup() могут потребовать выделение дополнительной памяти от системы.
Функция tfp_init()
Просто для справки, вот как реализована функция tfp_init(), которая используется во время инициализации библиотеки:
static pthread_key_t tfp_key;
static void
tfp_key_destroy (void *value)
{
tfp_signature_block_t *tfpblk;
tfpblk = (tfp_signature_block_t *) value;
if (tfpblk -> string) {
free (tfpblk -> string);
}
free (value);
pthread_setspecific (tfp_key, NULL);
}
static void
tfp_init (void)
{
pthread_key_create (&tfp_key, &tfp_key_destroy);
}
Это стандартное поведение POSIX функции pthread_once() - tfp_init() выполняет создание ключа (который хранится в глобальной переменной tfp_key) и связывает функцию деструктора tfp_key_destroy() с потоком для того чтобы локальные данные потока освобождались при его завершении.
tfp_read_raw_block() и tfp_write_raw_block() почти идентичны по своей функциональности tfp_mark(). Вот как выглядит tfp_write_raw_block() с некоторыми комментариями:
int
tfp_write_raw_block (char *ptr, int offset, int nbytes)
{
tfp_signature_block_t *tfpblk;
// обеспечить создание ключа (только один раз)
pthread_once (&tfp_oc, tfp_init);
if (offset < 0 || (offset + nbytes) >= TFP_RAW_BLOCK_LENGTH) {
return (EINVAL);
}
if ((tfpblk = pthread_getspecific (tfp_key)) == NULL) {
tfpblk = calloc (1, sizeof (*tfpblk));
if (tfpblk == NULL) {
return (errno);
}
memcpy (tfpblk, TFP_SIGNATURE_STRING, TFP_SIGNATURE_LENGTH);
pthread_setspecific (tfp_key, tfpblk);
}
memcpy (tfpblk -> raw_block + offset, ptr, nbytes);
time (&tfpblk -> t);
return (0);
}
Как вы можете убедиться, она почти идентична по реализации tfp_mark(). Единственное различие состоит в том, что блок двоичных данных имеет фиксированный размер, так что нам не нужно распределять и освобождать при каждом вызове - мы создаем его один раз. Так же, чтобы сделать код более универсальным, мы позволяем пользователю производить запись блока данных произвольной длины начиная с любой позиции. Для этого в самом начале функции проводится проверка на лимиты и memcpy() копирует в нужное место заданное количество байт.
Проблемы из-за отсутствия синхронизации
В исходных кодах есть одно место, заслуживающее особого обсуждения:
Проблема параллельного доступа к данным
Существует небольшая вероятность получить неверный результат когда поток изменяет данные в ключе и одновременно с этим утилита пытается их прочитать. Так как провал этих операций не критичен (то есть ни одна из конкурирующих программ не завершится аварийно) и может быть легко устранен (мы просто перезапустим утилиту и получим правильный результат) никаких особый действий предпринято не было.
Для тех, кому это интересно знать, один временной интервал для гонок возникает между записью сигнатуры и записью маркера или данных в блок. Другой интервал существует между освобождением tfpblk->string и созданием новой строки с установкой правильной длины. Правильным решением было бы запретить чтение блока, выполнить изменение данных и разрешить чтение блока после завершения изменений. Но и в этом случае мы имеем интервал неопределенности между моментом, когда TFP видит блок разрешенным на чтение и моментом, когда мы запрещаем его чтение.
Эта проблема не была исправлена просто потому, что вероятность такого случая очень мала и это восстановимая ошибка. Она могла бы быть полностью исправлена включением в структуру серийных номеров начала и завершения обновления данных. Перед началом изменения данных поток повышает "начальный" номер. После завершения работы с данными поток повышает "конечный" номер. Улита командной строки читала бы структуру целиком и сравнивала серийные номера. Если они не совпадают, то тогда бы утилита делала задержку на некоторое время и пыталась прочесть данные опять. Если номера совпадают, тогда данные можно использовать без проблем - раз номера одинаковы, значит данные в этот момент не изменяются другим потоком.
Утилита командной строки
Утилита командной строки состоит из следующих исходных файлов:
-
main.c
- Содержит разбор аргументов командной строки, начало выполнения программы.
- procfs.c
- Прочесывает файловую систему /proc; это основная часть, где делается вся "работа".
- procfs.h
- Содержит прототипы функций.
- tfp.h
- Основной заголовочный файл; содержит описания всего необходимого.
- tfp.use
- Информация по использованию файла (для use tfp из командной строки).
Функционирование
По стандартному соглашению QSSL для имен файлов в Makefile, файл с информацией по использованию утилиты имеет одно с ней имя tfp (аббревиатура от Thread Finger Print) с расширением .use.
Выполнение начинается с функции main() файла main.c, которая выполняет разбор командной строки. При разборе проверяется, задал ли пользователь одно или несколько имен процессов в командной строке и для каждого заданного имени процесса сразу вызывается show_tfp_name(). Если ничего не задано, то просто вызывается show_tfp_name(). Вот и всё, что есть в main.c.
Большая часть работы приходится на файл procfs.c. Функция show_tfp_name() выполняет всю основную обработку данных. Она принимает или имя процесса, и в этом случае производит поиск заданного процесса, или NULL, означающий обработку всех процессов. Для каждого процесса все его потоки проверяются на наличие POSIX ключа, имеющего нашу сигнатуру. Для любого потока с сигнатурой печатается информация, включая его идентификационную строку.
Заметьте, что утилита командной строки должна быть запущена от имени root, или быть SETUID root. Всё-таки, нам бы не хотелось что бы любой процесс мог иметь возможность копаться в адресном пространстве других процессов.
Реализация
Эта утилита демонстрирует использование следующих вещей:
доступ к файловой системе /proc
доступ к виртуальному адресному пространству процесса
обращение с областью локальных данных потока (thread-local storage - TLS)
Следующие функции являются определяющими:
opendir(), readdir(), closedir()
lseek(), read(), devctl() (с командами DCMD_PROC_MAPDEBUG_BASE и DCMD_PROC_TIDSTATUS)
Функции работы с директориями (opendir(), readdir(), и closedir()) используются show_tfp_name() для доступа к файловой системе /proc при поиске подходящих процессов. Функции lseek(), read() и devctl() используются для доступа к областям данных и информации потоков внутри процесса. TFP библиотека является на 100% POSIX совместимой, но так как не существует POSIX функций для доступа к локальным данным потока в другом процессе утилита командной строки реализована с применением специфичных только для Neutirno возможностей. (Для детального описания смотрите приложение "Файловая система /proc".)
Пошаговый анализ кода
Давайте взглянем на основные части исходного кода утилиты командной строки.
Мы не будет обсуждать функциональность main.c - это стандартный код и в большой степени он понятен и без объяснений. Для деталей реализации разбора командной строки посмотрите описание на getopt() из библиотеки C.
Все интересное начинается в procfs.c внутри функции show_tfp_name(). Вот сокращенная версия этой функции, показывающая принцип ее работы:
void
show_tfp_name (char *n)
{
struct dirent *dirent;
DIR *dir;
int fd;
int r;
int pid;
static struct {
procfs_debuginfo info;
char buff [PATH_MAX];
} name;
// 1) открыть директорию /proc
if (!(dir = opendir ("/proc"))) {
fprintf (stderr,
"%s: невозможно открыть /proc, errno %d\n", progname, errno);
perror (NULL);
exit (EXIT_FAILURE);
}
// 2) пройтись по всем записям в директории
while (dirent = readdir (dir)) {
// 3) пропустить все нечисловые имена
if (isdigit (*dirent -> d_name)) {
pid = atoi (dirent -> d_name);
if (pid == 1) {
continue; // пропустить менеджер процессов
}
// 4) открыть файл с именем ID процесса
sprintf (paths, "/proc/%d/as", pid);
if ((fd = open (paths, O_RDONLY)) == -1) {
continue; // ничего нельзя с этим поделать
}
// 5) получить имя процесса
if (devctl (fd, DCMD_PROC_MAPDEBUG_BASE, &name,
sizeof (name), 0) != EOK)
{
strcpy (name.info.path, "(n/a)");
}
// 6) проверить это ли имя мы ищем (если n != NULL)
if (n) {
if (strlen (name.info.path) > strlen (n)) {
r = strcmp (name.info.path + (strlen (name.info.path)
- strlen (n)), n);
} else {
r = strcmp (name.info.path, n);
}
if (r) { // не совпало
close (fd);
continue; // переходим к следующему
}
}
// 7) выполняем обработку процесса
process_pid (pid, fd, name.info.path);
close (fd);
}
}
closedir (dir);
}
Как вы видите, основная операция это просто обработка записей в директории и сравнение:
- Директория /proc для нас такая же как и любая другая - для ее чтения мы используем те же самые функции что и для нормальных директорий. Вызов opendir() дает нам дескриптор, который мы используем в последующих вызовах функций.
- Мы используем функцию readdir() для чтения следующей записи директории. Эта функция возвращает NULL, если все записи прочитаны, и мы прерываем наш цикл while.
- В файловой системе /proc есть несколько записей с нечисловыми именами (например, boot, dumper, qnetstats, и self). Они нас не интересуют, так что мы пропускаем всё, чьё имя не начинается с цифры. Имена, начинающиеся с цифры, мы преобразуем из ASCII строки в номер (который мы потом используем на шаге 7). Мы пропускаем менеджер процессов, так как в версии Neutrino, на которой я разрабатывал TFP, его обработка давала неправильный результат - число ключей для него было 134511032 с первым ключом в позиции 6. Не одно из этих значений не производило впечатление правильного, так что лучше было просто игнорировать менеджер процессов. Так как файловая система в основном не документирована мы (к несчастью) вынуждены прибегать в коде к обработке таких "специальных случаев".
- Записи с числовыми именами являются директориями, в каждой из которых существует файл с именем as (сокращение от "Address Space" - адресное пространство). Так как мы хотим покопаться именно в адресном пространстве процесса, мы открываем этот файл.
- Используя devctl() DCMD_PROC_MAPDEBUG_BASE мы можем получить имя процесса. (Этот вызов дает нам много другой полезной информации, но в данном случае она нас не интересует; смотрите "Файловая система /proc" в приложении к книге.)
- Мы сравниваем полученное имя с тем, которое передано как параметр. В этом есть немного магии, так как мы пытаемся сравнить только завершающий набор символов полученного имени.
- На этом шаге мы имеем подходящее нам имя (или потому что оно совпало с переданным именем или потому что для поиска всех процессов был задан NULL). Мы вызываем функцию process_pid() для выполнения настоящей "работы".
Может показаться, что самое интересное сосредоточено в process_pid(), тоже из файла procfs.c, но все, что она делает - это нахождение количества потоков в процессе. Потом она вызывает process_tid() для каждого потока:
static int printed_header; // флаг для внутреннего использования
static void
process_pid (int pid, int fd, char *n)
{
procfs_status status;
printed_header = 0;
status.tid = 1;
while (1) {
// спросить менеджер процессов о потоке
if (devctl (fd, DCMD_PROC_TIDSTATUS, &status,
sizeof (status), 0) != EOK)
{
break; // выходим, если вызов провалился
} else {
if (status.state != STATE_DEAD) {
if (optv) { // если в режиме подробной печати
// просто отладочная информация
printf ("P%d T%d S%d\n", pid, status.tid,
status.state);
}
process_tid (pid, status.tid, &status, fd, n);
}
status.tid++;// смотри примечания
}
}
}
Единственная мудреная часть в этом коде это прохождение через все потоки процесса. Если вы запустите pidin в Neutrino, вы заметите, что потоки внутри процесса не всегда имеют непрерывную нумерацию. Вы может видеть потоки с 1 по 9, а потом с 11 до 13, с пропуском потока 10. Это все в порядке вещей и вполне нормально - просто поток номер 10 отработал и уже завершился.
Таким образом, получение списка потоков в процессе мы должны начать с запроса о потоке с номером один (Neutrino начинает нумеровать потоки с единицы). Мы устанавливаем tid в структуре status в единицу и делаем запрос. Если запрос завершился неудачей, то мы выходим из цикла. Если запрос прошел успешно, мы вызываем для process_tid() для работы с потоком. Для перехода к следующему потоку мы увеличиваем tid на единицу и повторяем цикл. Если в индексах потоков есть промежуток, то ядро системы автоматически изменит tid до ближайшего существующего ID потока.
Прежде чем мы взглянем, как устроена process_tid(), нам важно понять чего мы пытаемся достичь. Вспомните, что ключевые данные POSIX хранятся в локальной области данных потока, которая расположена наверху стека каждого потока. Ключами в этих данных являются указатели типа void * и один из них указывает на блок данных подписанный нами. Внутри этого блока данных расположена сигнатура, штамп даты/времени и указатель на идентификационную строку потока.
То есть список задач, которые нам предстоит выполнить, будет:
Найти вершину стека для каждого потока.
Прочитать локальную область данных потока (thread local storage, TLS)
Установить нахождение массива keydata.
Разыменовать каждый член массива keydata и найти начало области данных на которую он указывает.
Просканировать эту область данных и проверить наличие нашей сигнатуры.
Если она не содержит сигнатуры, перейти к следующему ключу.
Если сигнатура найдена, разыменовать переменную и получить идентификационную строку потока (и блок двоичных данных, если это нужно).
Я проговорил инструкции одну за одной потому что мы часто принимаем как должное то, что компилятор делает со следующим кодом:
for (i = 0; i < tls -> numkeys; i++) {
p = (tfp_signature_block_t *) tls -> keydata [i];
if (!memcmp (p -> signature, TFP_SIGNATURE, TFP_SIGNATURE_LENGTH)) {
printf ("Id string is %s\n", p -> string);
}
}
Приведенный выше код фактически является "сердцем" того, что делается внутри process_tid(), и при этом process_tid() состоит из 136 строк (в 22 раза больше чем приведенный пример)! Почему? Разыменование!
Адресное пространство, которое мы пытаемся изучить нам не принадлежит. И вдобавок это виртуальное адресное пространство. Это означает, что у нет возможности использовать указатели этого адресного пространства. Поэтому мы должны разыменовать каждый без исключения указатель вручную.
Именно поэтому я и обратил ваше внимание на все эти мельчайшие детали выше в пошаговом описание, так как шестистрочный пример превращается в 136 строчный код приведенный ниже.
Вот этот код, обработка ошибок и проверка функциональности в котором удалена для краткости (комментарий "EC" означает что код проверки ошибок (error checking) пропущен):
static void
process_tid (pid_t pid, int tid, procfs_status *status, int fd, char *n)
{
struct _thread_local_storage their_tls;
uintptr_t their_keydata;
uintptr_t their_star_keydata;
int i, j;
int sts;
char buf [BUFSIZ];
tfp_signature_block_t tfpblk;
struct tm *tm;
// 1) TLS хранится в отладочной записи, переходим на нее
lseek (fd, status -> tls, SEEK_SET); // EC
// 2) прочитать копию TLS в наш процесс
read (fd, &their_tls, sizeof (their_tls)); // EC
// 3) получить базовый адрес массива keydata
their_keydata = (uintptr_t) their_tls.keydata;
if (their_tls.numkeys > 100) {
their_tls.numkeys = 100;
}
// 4) теперь читаем каждую запись из TLS элементов keydata
// до тех пор, пока не найдем нашу сигнатуру.
for (i = 0; i < their_tls.numkeys; i++) {
// 5) получить keydata [i]
lseek (fd, their_keydata + i * sizeof (uintptr_t), SEEK_SET); // EC
// 6) прочитать указатель
read (fd, &their_star_keydata, sizeof (uintptr_t)); // EC
// нет ключа, связанного с данным потоком; это нормально,
// просто пропускаем его
if (their_star_keydata == 0) {
continue;
}
// 7) разыменовать указатель (получить **keydata)
lseek (fd, their_star_keydata, SEEK_SET); // EC
// 8) прочитать блок данных TFP
read (fd, &tfpblk, sizeof (tfpblk)); // EC
// 9) проверить на наличие сигнатуры
if (!memcmp (tfpblk.signature, TFP_SIGNATURE_STRING,
TFP_SIGNATURE_LENGTH)) {
// we found it! блок TFP найден!
if (!printed_header) {
printf ("PID %9d \"%s\"'\n", pid, n);
printed_header = 1;
}
printf ("TID %5d", tid);
// 10) разыменовать и прочитать строку идентификации
lseek (fd, (uintptr_t) tfpblk.string, SEEK_SET); // EC
read (fd, buf, tfpblk.nstring); // EC
// 11) распечатать то, что мы нашли
tm = localtime (&tfpblk.t);
printf (" %04d %02d %02d %02d:%02d:%02d %s\n",
tm -> tm_year + 1900, tm -> tm_mon + 1,
tm -> tm_mday, tm -> tm_hour, tm -> tm_min,
tm -> tm_sec, buf);
// 12) распечатать дамп блока двоичных данных
if (optb) {
for (j = 0; j < TFP_RAW_BLOCK_LENGTH; j++) {
if ((j & 15) == 0) {
printf (" ");
}
printf ("%02X ", tfpblk.raw_block [j]);
if ((j & 15) == 15) {
printf ("\n");
}
}
if (j & 15) {
printf ("\n");
}
}
// 13) мы нашли наш ID, так что для этого потока больше делать нечего
return;
} else if (optv) {
printf ("\tКлюч не найден...\n");
}
}
}
Давайте проговорим наши шаги:
- Во время моих блужданий по <sys/debug.h> и <sys/procfs.h> я наткнулся на скрытое место, где прячется указатель на TLS. На этом шаге мы делаем lseek() что бы получить доступ к этому указателю. Не нужно забывать о том, что lseek() примененный в отношении к файлу as в действительности перемещает текущий указатель файла на определенный виртуальный адрес. И здесь мы перемещаем указатель файла на виртуальный адрес начала TLS.
- Для удобства мы копируем TLS в наше адресное пространство. Так же как lseek() работает с виртуальными адресами, read() тоже может оперировать с адресным пространством - в данном случае просто выполняя копирование памяти.
- Мы получаем содержимое поля keydata структуры TLS, которое хранит виртуальный адрес базы массива указателей void *.
Во время разработки программа иногда зависала. Тщательная проверка выявила, что причина этих зависаний кроется в несинхронизированном доступе к данным. Отсутствие синхронизации приводило к тому, что в то время как мы обращались к TLS, поле numkeys могло содержать мусор после завершения потока до тех пор пока ядро системы не удаляло данные потока полностью. Это приводило к тому, что мы пытались обратиться к практически бесконечному (и фиктивному) списку адресов в памяти удаленного процесса. Это не было катастрофой, за исключением того, что отнимало много времени для завершения такого цикла. Поэтому мы решили ограничить количество ключей для проверки и выбрали число 100 в качестве максимального предела. (Большинство программ Neutrino используют максимум четыре ключа, именно поэтому я подумал, что 100 в качестве лимита будет более чем достаточно. Принимая во внимание типичное применение POSIX функций для работы с ключами, этот лимит будет представлять собой количество применяемых библиотек.)
- На этом шаге мы знаем количество элементов TLS (поле numkeys), так что мы входим в цикл для их обработки.
- Здесь мы вызываем lseek() для получения адреса, на который указывает keydata [i].
- Мы разыменовываем keydata [i] для получения указателя.
- Это дает нам адрес, на который указывает keydata [i], так что теперь мы используем lseek() для перехода на него.
- Наконец-то мы можем прочитать данные, связанные с POSIX ключом.
- Используя memcmp() мы сверяем сигнатуру в блоке данных. Если она не совпала, переходим к следующему ключу.
- Если блок данных помечен нашей сигнатурой, мы печатаем кое-какую дополнительную информацию и извлекаем идентификационную строку.
- Этот шаг распечатывает информацию, которую мы получили.
- Этот шаг печатает блок двоичных данных (если указан параметр командной строки -b).
- После того как мы распечатали информацию о потоке остальные ключи нас не интересуют и мы выходим из цикла (это потому, что мы знаем что мы поддерживаем только один маркер на поток).
Как было сказано выше, постоянное использование lseek()/read() делает этот код громоздким. Если бы мы имели "родной" доступ к адресному пространству, мы бы просто использовали операторы C -> и *.
Вариации на тему
Хотя утилита идентификации потоков и является готовым продуктом, есть одна штука, которую я думал добавить в утилиту уже после сдачи ее заказчику. Только потому, что это отсутствовало в оригинальных требованиях, это никогда не было реализовано. Я говорю о возможности создания "именованных" потоков.
Рассмотрим стандартную POSIX функцию pthread_create():
int
pthread_create (pthread_t *thread,
const pthread_attr_t *attr,
void *(*func)(void *),
void *arg);
Если вы добавите всего один параметр, вы создадите POSIX-подобную функцию pthread_create_named():
int
pthread_create_named (pthread_t *thread,
const pthread_attr_t *attr,
void *(*func)(void *),
void *arg,
const char *name);
Последний параметр - имя, которое вы хотите присвоить создаваемому потоку.
Реализовать такую функцию труда не составит:
typedef struct pcn_s {
void *arg;
void *(*func) (void *);
const char *name;
} pcn_t;
static void *internal_worker (void *p);
int
pthread_create_named (pthread_t *thread,
const pthread_attr_t *attr,
void *(*func) (void *),
void *arg,
const char *name)
{
pcn_t *pcn;
pcn = (pcn_t *) malloc (sizeof (*pcn));
if (pcn == NULL) {
return (errno);
}
pcn -> arg = arg;
pcn -> name = name;
pcn -> func = func;
return (pthread_create (thread, attr, internal_worker, pcn));
}
static void *
internal_worker (void *p)
{
void *(*func) (void *);
void *arg;
pcn_t *pcn;
pcn = (pcn_t *) p;
tfp_mark (pcn -> name);
func = pcn -> func;
arg = pcn -> arg;
free (pcn);
return ((*func) (arg));
}
Все что мы сделали, это заставили pthread_create_named() вызвать внутреннюю рабочую функцию, которая выполняет маркировку потока и потом вызывает функцию потока. Заметьте, что к сожалению нам пришлось манипулировать с malloc() и free() - это потому, что pthread_create() принимает только одни аргумент, так что нам пришлось выделить массив для хранения всех параметров, которые нам нужно передать в рабочую функцию. Так как эти параметры необходимы для использования рабочей функции, они должны существовать на все время выполнения низкоуровневой функции потока, даже после разрушения стека pthread_create_named().
Ссылки
К этой главе относятся следующие ссылки.
Заголовочные файлы
- <pthread.h>
- Содержит намеки о том, как реализована pthread_getspecific().
- <sys/debug.h>
- Содержит определения структур debug_process_t и debug_thread_t, которые используются для получения данных процесса и потока с помощью devctl().
- <sys/procfs.h>
- Содержит команды devctl() используемые для извлечения информации из файловой системы /proc.
- <sys/storage.h>
- Содержит определение локальной памяти потока, struct _thread_local_storage.
- <sys/neutrino.h>
- Содержит определения состояний потока. Мы использовали эти значения для проверки что поток, с которым мы работаем, все еще жив.
Функции
Смотрите следующие функции в документации по библиотеке С системы Neutrino: closedir(), devctl(), lseek(), opendir(), pthread_key_create(), pthread_getspecific(), pthread_setspecific(), read(), и readdir().
Книги
Глава 5.4 книги Programming with POSIX Threads, Butenhof (ISBN 0-201-63392-2) обсуждающая локальную память потока.
|