可重入与线程安全

线程安全(thread safety)是指在多线程环境下,不同的线程在同一时刻能够安全访问临界区的能力,它可以让代码没有副作用地实现想要的功能。
可重入(reentrancy)是指一个函数如果在执行过程中被中断,当中断完成后又可以安全地进入上次中断点重新执行的能力。它有两种语义:

  • 在多线程环境下,一个线程因时间片使用完了(或者其他原因),另一个线程开始运行,接着该线程又安全地重新开始运行。在这种语境下,可重入等同于线程安全。
  • 在单线程的信号处理环境下,一个函数在运行过程中,此时异步来了个信号,控制流便转向了信号处理函数,当信号处理函数完成后该函数又可以安全地重新运行。在这种语境下,可重入又被称为异步信号安全(async-signal safety)。

当提到可重入的时候,我们一般指的是后者。


可重入

为了使函数达到可重入,需要遵循一定的规则,如下

  1. 不要包含静态数据,不要使用全局数据。
int global_var{10};

int NotReentrant()
{
  global_var = 20;
  // 在这里来了个信号
  return global_var;
}

如上所示,如果给 global_var 赋值之后来了个信号,在信号处理函数中又对 global_var 赋了不同的值,那么从信号处理函数返回到 NotReentrant 中,global_var 的值就不再是我们期望的值,因此该函数是不可重入的。
这个例子比较直观,信号也可能在一些不太直观的地方中发送过来。例如,在一个 32 位的机器上操作 64 位的数据,这个操作可能就要被分为两个 32 位的操作,而在这两个操作之间,信号就有可能被发送过来;对于 global_var = f() + g();f()g() 发生的先后顺序是不确定的,而且信号也可能在两个函数之间被发送过来。

  1. 不要使用 newmalloc)或 deletefree)。

不同实现中的 new 是不同的,可以是线程安全的也可以是线程不安全的,但无论如何都是不可重入的。
先假设它是线程不安全的。new 通常为它在堆上分配的存储区维护一个链表,而当信号来的时候,线程可能正在修改此链表,而信号处理函数中也可能调用了 new,也要修改链表,这就造成了冲突。因此线程不安全的 new 是不可重入的。
再假设它是线程安全的。这时候就要在修改链表的地方加上锁,如果在加上锁之后但还没有修改完链表的时候来了个信号,在信号处理函数中也调用了 new,也要加上锁,如果该锁不是递归的,那么该线程将会永久地等待该锁的释放,无法将控制流返回到之前的函数中。因此线程安全的 new 也是不可重入的。
在本文的测试环境中(Ubuntu-16.04-64bit GCC-5.4.0),newmalloc)和 deletemalloc)都是线程安全的。

  1. 不要使用不可重入的函数。

特别需要注意的是标准 I/0 函数,标准 I/O 库中的很多实现都以不可重入方式使用了全局数据。若标准 I/O 指向的是终端,则它是行缓冲的,否则是全缓冲的。例如对于 printf,并不是调用它就会立即将全局缓冲数据冲洗(flush),而是当遇到了换行符(行缓冲)或者是缓冲区满了(全缓冲)才会将数据传送。由于使用了全局数据,因此 printf 是不可重入的,不能将它用在可重入的函数中。

在本文的测试环境下,有些函数是不可重入的,例如 strerrorreaddir,但是系统提供了可重入的版本 strerror_rreaddir_r(后缀 r 表示 reentrant),这些可重入版本不再使用静态数据,而是需要调用者提供由自己管理的存储空间。
信号处理函数也需要是可重入的,当控制流在信号处理函数 A 中时,也可能会有另外的信号发送过来,如果此时的信号屏蔽字没有将该信号屏蔽掉,那么就会转到相应的信号处理函数 B 中,如果信号处理函数 A 和 B 都修改了同一个全局变量,那么结果将会是意料之外的。
对于以上的规则,errno 是一个例外,每个线程都会有自己的 errno,Single UNIX Specification 中要求的可重入函数(详见 APUE 第三版 10.6)也可能会出错,从而修改了 errno,但是依然认为这些函数是可重入的,所以如果在信号处理函数中调用了这些函数,需要在该信号处理函数开始的位置保存 errno,在函数的末尾再把保存的值重新赋给 errno


可重入与线程安全的区别

我们经常将可重入与线程安全视为相同的,但是它们之间还是有细微的差别。在多线程环境下,可重入即为线程安全;但是更常使用的语境是单线程的信号处理,因为满足了上述可重入的三个规则的函数,大多同时也是线程安全的,所以通常并不对其进行区分,但是也会有特殊的情况。

是可重入却是线程不安全

int global_var{20};

void Swap(int* lhs, int* rhs)
{
  int save{global_var};

  global_var = *lhs;
  *lhs = *rhs;
  // 假如信号在此时传来
  *rhs = global_var;

  global_var = save;
}

这种做法就类似与上文对 errno 的处理,先将 global_var 保存起来,在末尾的地方再还回去。如果信号在 Swap 中途传来,也不用担心控制流重新回来的时候 global_var 会发生改变,因此是可重入的;但是由于没有对临界区锁起来,这个函数就是线程不安全的。

是线程安全却是不可重入

上文中的线程安全的 new 就是一个例子。


参考

[1] Reentrancy(computing)
[2] Thread safety
[3] why are malloc and printf said as non-reentrant

你可能感兴趣的:(可重入与线程安全)