Всем здравствуйте. Я расскажу о различных, подчас интересных, эффектах, которые возникают при работе с POSIX сигналами в Windows. Затаривайтесь Вискасом и вперёд.

Полёт будет долгий
Полёт будет долгий

Конечно же не могу не упомянуть гибкость механизма сигналов POSIX. Этот механизм реализует подобие парадигмы событийного программирования в C#, чем он, собственно, и интересен. Обычно, в коде, написанном на C/C++ мы вызываем функции ядра, например внутри getch(). А вот когда ядро вызывает нас, происходят всякие интересные эффекты: такие как асинхронные вызовы и произвольная логика программы, изменение способа обработки исключительных ситуаций. Чем то напоминает callback.

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

Я продемонстрирую несколько экспериментов с сигналами, в которых обнаруживаются не совсем очевидные вещи. В качестве примеров я выбрал обработчики сигналов SIGINT и SIGABRT. Обработчик SIGINT вызывается асинхронно, в ответ на нажатие клавиш CTRL+C. Обработчик SIGABRT вызывается в ответ на необработанную исключительную ситуацию, но не всегда. Я объясню особенности, возникающие в зависимости от способа генерации сигналов и наличия отладчика. Обратите внимание на то, что все эксперименты проводились в Windows, а исходный код компилируется как код C++.

Содержание

  1. Обработчики сигналов запускаются в разном контексте в зависимости от того, каким образом сгенерирован сигнал.

  2. Обработка исключения, в зависимости от контекста обработчика сигнала.

  3. Обработчик сигнала SIGABRT вызывается, если программа выполняется не под отладчиком.

Исходный код программы

#include <windows.h>

#include <stdlib.h>
#include <stdio.h>
#include <conio.h>

#include <signal.h>
#include <errno.h>

#define SIGINT_RAISE

int _get_thread_id()
{
	HANDLE hThread = 0;
	DWORD dwId = 0;
	hThread = GetCurrentThread();
	dwId = GetThreadId(hThread);
	return (int)dwId;
}

void funcabrt(int sig)
// обработчик неперехваченного исключения
{
	_set_abort_behavior(0, _CALL_REPORTFAULT);
	perror("funcabrt");
	printf("Thread ID: %d\n", _get_thread_id());
	_getch();
};

void funcint(int sig)
// обработчик нажатия клавиш CTRL+C
{
	try
	{
		printf("funcint\n");
		printf("Thread ID: %d\n", _get_thread_id());
		_getch();
		throw (int)ERANGE;
	}
	catch (int err)
	{
		_set_errno(err);
		throw;
	}
	
};

int main()
{
#if defined(SIGINT_RAISE)
	printf("Raise SIGINT mode on\n");
#else
	printf("Raise SIGINT mode off\n");
#endif 
	printf("main\n");
	printf("Thread ID: %d\n", _get_thread_id());
	try
	{
		if (signal(SIGABRT, funcabrt) == SIG_ERR) exit(EXIT_FAILURE);
		if (signal(SIGINT, funcint) == SIG_ERR) exit(EXIT_FAILURE);
#if defined(SIGINT_RAISE)
		raise(SIGINT);
#endif 
		while (1); // нажмите CTRL+C вылетит птенец
	}
	catch (int err)
	{
		printf("catch\n");
		_getch();
	};
};

Обработчики сигналов запускаются в разном контексте в зависимости от того, каким образом сгенерирован сигнал.

Обработчик сигнала SIGINT
void funcint(int sig)
// обработчик нажатия клавиш CTRL+C
{
    try
    {
        printf("funcint\n");
        printf("Thread ID: %d\n", _get_thread_id());
        _getch();
        throw (int)ERANGE;
    }
    catch (int err)
    {
        _set_errno(err);
        throw;
    }
    
};

Случай, когда сигнал возбужден программой Обработчик запускается в контексте нашей программы.

Вывод

Raise SIGINT mode on
main
Thread ID: 5380
funcint
Thread ID: 5380

Смотрим стек вызовов

ntdll.dll!775270f4()
ntdll.dll![Указанные ниже кадры могут быть неверны или отсутствовать, символы для ntdll.dll не загружены]
ntdll.dll!775264a4()
kernel32.dll!76054b6e()
kernel32.dll!760acf97()
kernel32.dll!760ad071()
ucrtbase.dll!0fb5ed35()
ucrtbase.dll!0fb5ec74()
experiment.exe!funcint(int sig)
ucrtbase.dll!0fb2ca9a()
experiment.exe!main()
[Внедренный фрейм] experiment.exe!invoke_main()
experiment.exe!__scrt_common_main_seh()
kernel32.dll!7604ed6c()
ntdll.dll!775437eb()
ntdll.dll!775437be()

Мы видим, что вызов обработчика происходит в основном потоке, посредством библиотеки выполнения C Runtime

Случай, когда сигнал возбужден ядром. Это стандартная ситуация, когда сигнал SIGINT возбуждается при нажатии на клавиатуре CTRL+C при работе консольных приложений. Обработчик запускается в контексте ядра в отдельном потоке.

Вывод

Raise SIGINT mode off
main
Thread ID: 976
funcint
Thread ID: 2848

Смотрим стек вызовов потока 2848

ntdll.dll!775270f4()
ntdll.dll![Указанные ниже кадры могут быть неверны или отсутствовать, символы для ntdll.dll не загружены]
ntdll.dll!775264a4()
kernel32.dll!76054b6e()
kernel32.dll!760acf97()
kernel32.dll!760ad071()
ucrtbase.dll!0fb5ed35()
ucrtbase.dll!0fb5ec74()
experiment.exe!funcint(int sig)
ucrtbase.dll!0fb2c72d()
kernel32.dll!7607e3d8()
kernel32.dll!7604ed6c()
ntdll.dll!775437eb()
ntdll.dll!775437be()

Мы видим, что вызов обработчика происходит в потоке kernel32.dll, посредством библиотеки выполнения C Runtime

Обработка исключения, в зависимости от контекста обработчика сигнала.

int main()
{
#if defined(SIGINT_RAISE)
	printf("Raise SIGINT mode on\n");
#else
	printf("Raise SIGINT mode off\n");
#endif 
	printf("main\n");
	printf("Thread ID: %d\n", _get_thread_id());
	try
	{
		if (signal(SIGABRT, funcabrt) == SIG_ERR) exit(EXIT_FAILURE);
		if (signal(SIGINT, funcint) == SIG_ERR) exit(EXIT_FAILURE);
#if defined(SIGINT_RAISE)
		raise(SIGINT);
#endif 
		while (1); // нажмите CTRL+C вылетит птенец
	}
	catch (int err)
	{
		printf("catch\n");
		_getch();
	};
};

Здесь в том случае, если сигнал сгенерирован программой, возбужденное исключение попадёт в блок try/catch. Причина следует из особенностей, которые я описал в п.1. При возбуждении исключения происходит раскрутка стека, т.е. поиск обработчиков в генерирующей сигнал (и фактически вызвавшей её обработчик) функции.

Вывод

Raise SIGINT mode on
main
Thread ID: 4676
funcint
Thread ID: 4676
catch

А вот если SIGINT сгенерирован ядром, исключение не найдёт своего обработчика и отправится в отладчик, либо будет сгенерирован SIGABRT. Точнее найдёт, возможно, в неграх ядра есть SEH обработчик верхнего уровня (системный), до которого исключение в конце концов добирается и уже этот обработчик производит фактически вызов обработчика SIGABRT, который находится в нашей программе. Однако, зафиксировать этот факт при отладке не представляется возможным.

Обработчик сигнала SIGABRT вызывается, если программа выполняется не под отладчиком.

Возбуждаем пустое исключение.

throw;

Если программа выполняется под отладчиком, неперехваченное исключение попадает в отладчик, т.к. никаких обработчиков для такого исключения не предусмотрено.

Вывод

Raise SIGINT mode off
main
Thread ID: 2940
funcint
Thread ID: 5372

Отладчик

Если программа выполняется без присоединённого отладчика, происходит генерация SIGABRT

Обработчик сигнала SIGABRT
void funcabrt(int sig)
// обработчик неперехваченного исключения
{
	_set_abort_behavior(0, _CALL_REPORTFAULT);
	perror("funcabrt");
	printf("Thread ID: %d\n", _get_thread_id());
	_getch();
};

Вывод

Raise SIGINT mode off
main
Thread ID: 5748
funcint
Thread ID: 2732
funcabrt: Result too large
Thread ID: 2732

Итоги:

  1. Если мы генерируем сигнал в программе, то обработчик сигнала вызывается как обычная функция Если сигнал генерирует ядро, то обработчик сигнала вызывается в отдельном потоке

  2. Если программа выполняется под отладчиком, все сгенерированные и необработанные исключения перехватывает отладчик. Если программа выполняется без отладчика, вызывается обработчик сигнала SIGABRT


Комментарии (6)


  1. tmk826
    04.12.2021 15:51
    +1

    Ненормальное программирование

    100% в точку. Исследовать POSIX сигналы под виндоус...


    1. staticmain
      04.12.2021 16:05
      +4

      printf("funcint\n");
              printf("Thread ID: %d\n", _get_thread_id());


      Не говоря уже о том, что printf — не реентерабельная функция, и использовать её внутри обработчика асинхронного сигнала нельзя.


      1. NTDLL Автор
        04.12.2021 17:52
        -2

        А если использовать мьютекс для блокировки общего ресурса, кем является поток вывода.


        1. INSTE
          04.12.2021 18:32

          Тогда при следующем сигнале получаем deadlock и все.
          Не знаю как в винде, возможно своя специфика, но в POSIX подробно описано что такое async-signal-safe и что гарантировано им является.


        1. mayorovp
          04.12.2021 19:20

          Ну вот вы захватили мьютекс, вызвали printf. Тут пришёл сигнал, и вы снова захватываете мьютекс чтобы вызвать printf. Результат — либо ошибка, либо взаимоблокировка. И даже если вы используете реентерабельную критическую секцию, вызов printf пока выполняется printf всё равно будет ошибкой.


  1. fareloz
    06.12.2021 11:44

    Предлагаю обратить внимание на

    std::this_thread::get_id()

    для получения id потока вместо самопальной функции.