线程特定数据和多线程应用程序中的信号处理

作者:Martin McCarthy

关于多线程编程,我被问到的最常见的两个问题(在“什么是多线程编程?”和“你为什么要这样做?”之后)是关于如何处理信号,以及如何处理两个并发线程使用一个使用全局数据的通用函数,但这两个线程需要来自该函数的线程特定数据的情况。按照定义,全局数据包括静态局部变量,它们实际上是一种全局变量。在本文中,我将解释如何在 C 程序中使用可用于 Linux 的 POSIX(或几乎 POSIX)多线程包之一来处理这些问题。我希望有一天,关于多线程编程,我被问到的最常见的问题是,“我们可以给你很多钱来编写这个简单的多线程应用程序吗,拜托?” 嘿——我可以做梦,不是吗?

本文中的所有示例都使用了符合 POSIX 标准的功能。据我所知,在我撰写本文时,Linux 还没有完全符合 POSIX 标准的多线程库。哪个可用的库最好是一个主观问题。我使用 Xavier Leroy 的 LinuxThreads 包,代码片段和示例是使用该库的 0.5 版本进行测试的。这个包可以从 http://pauillac.inria.fr/~xleroy/linuxthreads 获取。Christopher Provenzano 有一个很好的用户级库,尽管信号处理还不符合规范,而且在我上次使用它时仍然存在一些严重的错误。(我相信,这些错误正在被修复。)还有其他的库实现可用。关于这些和其他软件包的信息可以在 comp.programming.threads 新闻组中找到,以及(为了给出一个不完全详尽的列表)

  • http://www.mit.edu:8001/people/proven/pthreads.html

  • http://www.aa.net/~mtp/PCthreads.html

  • ftp://ftp.cs.fsu.edu/pub/PART/PTHREADS

线程特定数据

正如我在上面暗示的那样,我使用术语“全局数据”来指代任何超出正常作用域规则持续存在的数据,例如静态局部变量。给定一段像这样的代码

void foo(void)
{
        static int i = 1;
        printf( "%d\n", i );
        i = 2;
}

第一次调用此函数将打印值 1,所有后续调用将打印值 2,因为变量 i 及其值从一个函数调用持续到下一个函数调用,而不是像“普通”局部变量那样在一缕烟雾中消失。至少就 POSIX 线程而言,这是全局数据。

人们常说(我自己也说过)使用全局数据是一种不好的做法。无论这是否属实,这只是一条经验法则。当然,在某些情况下,使用全局数据可以避免人为地制造情况。前一篇文章(《Linux Journal》第 34 期)解释了线程如何通过小心使用互斥(mutex)函数来共享全局数据,以防止一个线程在另一个线程更改全局数据项的值时访问该数据项。在本文中,我将研究一种不同类型的问题,使用来自我最近项目的一个真实示例。

考虑虚拟现实系统的案例,其中客户端与服务器建立多个网络套接字连接。不同类型和优先级的数据通过不同的套接字传输。高优先级数据,例如关于客户端视野中立即出现的对象的信息,通过一个套接字发送。较低优先级的数据,例如纹理信息、背景声音或关于当前视野之外的对象的信息,通过另一个套接字发送,以便在客户端有空闲时间时进行处理。服务器可以在每次新客户端连接到服务器时创建一组新线程,为每个套接字指定一个线程,用于与每个客户端通信。所有这些线程都可以使用相同的函数来向客户端发送一大块数据(不是技术术语)。要发送的数据、客户端的详细信息、要发送的数据的优先级和类型都可以保存在全局变量中,但每个线程将使用不同的值。那么我们该怎么做呢?

作为一个简单的例子,假设我们的数据块发送函数需要使用的唯一全局数据是一个指示数据优先级的整数。在非线程版本中,我们可能有一个名为 priority 的全局整数,如 列表 1 中所示。

在多线程版本中,我们没有全局整数,而是有一个指向整数的全局键。数据可以通过键,通过许多函数来访问

  1. pthread_key_create() 用于准备键以供使用

  2. pthread_setspecific() 用于为线程特定数据设置值

  3. pthread_getspecific() 用于检索当前值

pthread_key_create() 只调用一次,通常在任何将要使用该键的线程创建之前调用。如果用作参数的键尚未创建,则 pthread_getspecific()pthread_setspecific() 永远不会返回错误。在尚未创建的键上使用它们的结果是未定义的。某些事情会发生,但它可能因系统而异,并且不能简单地通过使用良好的错误处理来捕获。这是粗心大意的人的优秀 bug 来源。因此,我们的多线程版本可能看起来像 列表 2

这里有几件事需要注意

  1. POSIX 线程的实现可能会限制进程可以使用的键的数量。标准规定这个数字必须至少为 128。任何实现中可用的数量都可以通过查看宏 PTHREAD_KEYS_MAX 来找到。根据这个宏,LinuxThreads 目前允许 128 个键。

  2. 函数 pthread_key_delete() 可用于处理不再需要的键。键,就像所有“正常”数据项一样,在进程退出时会消失,那么为什么要费心删除它们呢?将键处理视为类似于文件处理。一个不复杂的程序不需要关闭它打开的任何文件,因为它们会在程序退出时自动关闭。但是由于程序一次可以打开的文件数量有限制,因此最好的策略是关闭当前未使用的文件,以便不超过限制。此策略也适用于键处理,因为您可能会受到进程可以拥有的线程特定数据键数量的限制。

  3. pthread_getspecific()pthread_setspecific() 将线程特定数据作为 void* 指针访问。如果要访问的数据项可以强制转换为 void* 类型,例如,在大多数但并非所有实现中为 int,则可以直接使用此功能(如列表 2 中所示)。但是,如果您希望您的代码具有可移植性,或者如果您需要访问更大的数据对象,那么每个线程都必须为数据对象分配足够的内存,并将指向该对象的指针存储在线程特定数据中,而不是存储数据本身。

  4. 如果您为线程特定数据分配了一些内存(例如,使用标准函数 malloc()),并且线程在某个时刻退出,那么分配的内存会发生什么?什么也不会发生,所以它会泄漏,这很糟糕。这就是 pthread_key_create() 函数中的额外参数派上用场的情况。此参数允许您指定在线程退出时调用的函数,您可以使用该函数释放已分配的任何内存。为了防止浪费 CPU 时间,此析构函数仅在线程使用了特定键的情况下才被调用。为没有要清理的线程进行清理意义不大。当线程因为调用了函数 exit()_exit()abort() 而退出时,不会调用析构函数。另请注意,pthread_key_delete() 不会导致任何析构函数被调用,使用已删除的键的行为未定义,并且 pthread_getspecific()pthread_setspecific() 不返回任何错误指示。仔细清理您的键。总有一天你会很高兴你这样做了。因此,我们代码的更好版本是 列表 3

起初,这些代码看起来可能有点奇怪。使用 pthread_getspecific() 来存储线程特定值?其思想是获取此线程要使用的内存位置,然后将线程特定值存储在那里。

即使全局数据对您来说是令人厌恶的,您仍然可以很好地利用线程特定数据。特别是,您可能需要编写一些现有库代码的多线程版本,该版本也将在非线程程序中使用。一个好的简单示例是制作一个适用于多线程程序的标准 C 库版本。所有 C 程序员的朋友 errno 是一个全局变量,通常由库函数设置,以指示函数调用期间发生了什么错误。如果两个线程调用都将 errno 设置为不同值的函数,则至少有一个线程将获得错误的信息。这可以通过为 errno 设置线程特定数据区域而不是所有线程使用的一个全局变量来解决。

信号处理

许多人发现 C 语言中的信号处理在最好的情况下也很棘手。多线程应用程序在信号处理方面需要格外小心,但是一旦您编写了两个程序,您就会想知道所有的 fuss 是怎么回事——相信我。如果您开始恐慌,请记住——深呼吸,缓慢呼吸。

快速回顾一下什么是信号。信号是系统通知进程各种事件的方式。信号有两种类型:同步信号和异步信号。

同步信号是程序操作的结果。两个例子是

  1. SIGFPE,浮点异常,当程序尝试执行一些非法数学运算(例如除以零)时返回。

  2. SIGSEGV,段错误,当程序尝试访问其合法访问区域之外的内存区域时返回。

异步信号独立于程序。例如,当用户给出 kill 命令时发送的信号。

在非线程应用程序中,通常有三种处理信号的方法

  1. 假装它们不存在,这可能是最常见的策略,并且对于许多简单的程序来说非常足够——至少在您希望您的程序可靠且有用之前。

  2. 使用 signal() 设置信号处理程序——简单易用,但不是很健壮。

  3. 使用 POSIX 信号处理函数(例如 sigaction()sigprocmask())来设置信号处理程序或忽略某些信号——“正确”的方法。

如果您选择第一个选项,那么信号将具有一些默认行为。通常,这种默认行为将导致程序退出或导致程序忽略信号,具体取决于信号是什么。后两个选项允许您更改每种信号类型的默认行为——忽略信号、导致程序退出或调用信号处理函数以允许您的程序执行一些特殊处理。避免使用旧式的 signal() 函数。无论您编写的是线程应用程序还是非线程应用程序,POSIX 样式函数的额外复杂性都是值得的。请注意,sigprocmask()(为进程设置信号掩码)在多线程程序中的行为是未定义的。有一个新函数 pthread_sigmask(),它以与 sigprocmask() 非常相似的方式使用,但它仅为当前线程设置信号掩码。此外,新线程继承创建它的线程的信号掩码;因此,通过在创建任何线程之前调用 pthread_sigmask(),可以有效地为整个进程设置信号掩码。

在多线程应用程序中,始终存在信号实际传递给哪个线程的问题。还是它会被传递给所有线程?

首先回答最后一个问题,不。如果生成一个信号,则传递一个信号,因此任何单个信号都只会传递给单个线程。

那么哪个线程将获得信号?如果是同步信号,则信号将传递给生成它的线程。同步信号通常通过在每个线程中设置适当的信号处理程序来处理任何未被屏蔽的信号来管理。如果是异步信号,它可能会传递给任何未通过 sigprocmask() 屏蔽该信号的线程。这使生活更加复杂。例如,假设您的信号处理程序必须访问全局变量。这通常可以通过使用 mutex 来愉快地处理,如下所示

void signal_handler( int sig )
{
        ...
        pthread_mutex_lock( &mutex1 );
        ...
        pthread_mutex_unlock( &mutex1 );
        ...
}

乍一看似乎不错。但是,如果被信号中断的线程刚刚自己锁定了 mutex1 怎么办?signal_handler() 函数将阻塞,并将等待互斥锁被解锁。而当前持有互斥锁的线程将不会重新启动,因此在信号处理程序退出之前将无法释放互斥锁。一个不错的致命拥抱。

因此,在多线程程序中处理异步信号的一种常见方法是在所有线程中屏蔽信号,然后创建一个单独的线程(或多个线程),其唯一目的是捕获信号并处理它们。信号处理程序线程通过调用函数 sigwait() 并提供它希望等待的信号的详细信息来捕获信号。要给出一个关于如何完成此操作的简单示例,请查看 列表 4

如前所述,线程从创建它的线程继承其信号掩码。main() 函数设置信号掩码以阻止所有信号,因此在此点之后创建的所有线程都将阻止所有信号,包括信号处理线程。乍一看可能很奇怪,但这正是我们想要的。信号处理线程期望信号信息由 sigwait() 函数提供,而不是直接由操作系统提供。sigwait() 将取消屏蔽给它的信号集,然后将阻塞,直到其中一个信号发生。

此外,您可能会认为如果信号在主线程持有互斥锁 sig_mutex 时引发,则此程序将死锁。毕竟,信号处理程序尝试获取同一个互斥锁,它将阻塞直到该互斥锁空闲。但是,主线程正在忽略信号,因此没有任何东西可以阻止另一个线程在信号处理线程被阻塞时获得控制权。在这种情况下,sig_handler() 没有以通常的非线程意义捕获信号。相反,它要求操作系统告知它何时引发了信号。操作系统已执行此功能,因此信号处理线程成为另一个正在运行的线程。

POSIX 线程和 LinuxThreads 之间信号处理的差异

列表 4 显示了如何在以 POSIX 兼容方式处理线程的多线程环境中处理信号。

就我个人而言,我喜欢内核级包“LinuxThreads”,它使用 Linux 2.0 的 clone() 系统调用来创建新线程。在未来的某个时候,clone() 调用可能会实现 CLONE_PID 标志,这将允许所有线程共享一个进程 ID。在此之前,使用“LinuxThreads”(或任何其他选择使用 clone() 创建线程的包)创建的每个线程都将拥有自己唯一的进程 ID。因此,没有将信号发送到“进程”的概念。如果一个线程调用 sigwait() 并且所有其他线程都阻止信号,则只有专门发送到 sigwait() 等待线程的信号才会被处理。根据您的应用程序,这可能意味着您别无选择,只能在每个线程中包含一个异步信号处理程序。

总结

线程特定数据易于使用——远比许多人的第一印象所暗示的要容易得多。在某种程度上,这种易用性是一个缺点,因为通常有更优雅的解决方案来解决问题。但在需要时,线程特定数据是您的朋友。

另一方面,认真对待信号处理可能会有点棘手。任何不这样认为的人都忽略了一些东西——要么就是他们太聪明了,聪明过头了。通过将所有异步信号的处理交给一个位于 sigwait() 上的线程来简化您的生活。

Martin McCarthy 在编写高速、多用户、分布式、虚拟现实系统的服务器时发现了多线程编程。当然,他只是为了尽可能多地将流行语塞进他的职位描述中才接受了这份工作。可以通过 marty@ehabitat.demon.co.uk 联系到他。

加载 Disqus 评论