转自:http://www.ibm.com/developerworks/cn/linux/l-pthred/
|
|
级别: 初级
Peter Seebach ([email protected]), 自由作者
2004 年 3 月 01 日
线程问题是令许多程序员头痛的问题。UNIX 的进程模型简单易懂,但有时效率低下。线程技术通常能使性能得到实质性的改进,付出的代价就是代码有点混乱。本文揭开了 POSIX 线程接口的神秘面纱,并提供了线程化代码的实际例子作为参考。
除了 Anne McCaffrey 的系列小说 Dragonriders of Pern 之外,“线程”是令程序员谈虎色变的词儿。线程有时称为轻型进程,是与大型复杂的项目相关的。调用库函数时经常会遇到一些“线程不安全”的可怕警告。但这些线程究竟是什么?使用它们能做什么?使用线程有什么风险?
本文通过一个简单的线程应用程序来介绍线程。使用的线程模型是 POSIX 线程接口,通常称为 pthreads。本文例子基于 SuSE Linux 8.2 平台。所有代码都在 SuSE Linux 8.2 上和 NetBSD-current 的最新构建上测试通过。
什么是线程?
线程和进程十分相似,不同的只是线程比进程小。首先,线程采用了多个线程可共享资源的设计思想;例如,它们的操作大部分都是在同一地址空间进行的。其次,从一个线程切换到另一线程所花费的代价比进程低。再次,进程本身的信息在内存中占用的空间比线程大,因此线程更能允分地利用内存。
线程之间通常需要进行交互,因此就存在使用 IPC 进行多进程通信的问题。本文中对于多进程通信问题不做过多的讨论,因为 POXIS 线程 API 提供了处理诸如死锁和竞态条件这类问题的工具。本文主要讨论特定于多线程编程的问题和解决方案,一般的多道程序设计问题留待以后讨论。
线程程序有时会出现在多进程和 IPC 程序设计中不常出现的一些问题。例如,如果两个线程同时调用一个函数,如 asctime()
(它使用一个静态的数据区),会产生不可思议的结果。这是“线程安全”要考虑的问题。
|
一个简单程序
本文使用的示例程序是一个投骰子程序。人们在玩角色扮演游戏或者战争游戏时,经常会把很多的时间花费在掷骰子上。一个可以通过网络存取的掷骰子程序在许多方面适合作为示例程序。其中程序代码非常简单是最重要的原因,这样对程序逻辑的理解就不会影响对线程的理解。
最使人分心的是网络部分,为了尽量简化我们的学习过程,这部分代码全都封装在一些子程序中。第一个子程序 socket_setup()
,它返回一个准备接受连接的套接字。第二个子程序 get_sockets()
,以这个套接字为参数,接受连接,并创建一个 struct sockets
对象。
struct sockets
对象只是一个对输入/输出端口的抽象描述。一个 FILE *
用于输入,另一个用于输出,还有一个标志用来提醒我们以后正确地关闭套接字。
您可以从 参考资料以 tar 压缩格式下载该程序的不同版本,并在单独的 shell 中查看或运行这些程序,或者在单独的浏览器窗口中查看。第一个版本是 dthread1.c。
让我们仔细分析一下这个程序 dthread1
。它具有几个选项。第一个选项(当前未用,为功能扩展保留)是一个调试标志,用选项 -d
表示。第二个选项是 -s
,它决定程序是否在控制台环境运行。如果没有指定 -s
选项,程序将会监听连接并通过连接进行会话。第三个选项 -t
确定是否运行多线程。如果没有指定 -t
选项,程序将只处理一个连接,处理完成后就退出。最后一个是 -S
选项,它使程序在每两次投掷间停顿一秒。使用这个选项只是为了有趣,因为这样我们就容易看到多个连接间的交替过程。
|
我们来好好研究一下这个程序。要该程序一运行就连接到它,请尝试 telnet localhost 6173
。如果以前您对这类掷骰子的游戏不熟悉,那么请 2d6 开始
。所支持的一般语法是“NdX”,意思是投 N 个骰子,每个骰子可以投出的范围从 1 到 X(从代码中可以看出,程序实际是投一个具有 X 面的骰子 N 次)。
这里看到了线程程序最简单的形式。有一点需要注意的是:每个线程都只是处理自己独特的数据。(这并不完全正确;如果您发现了这个错误,说明您很有观察力)。这样可以避免各个线程相互交叠。为此, pthread_create()
将接受一个 void *
类型的参数,这个参数会被传到线程开始执行的那个函数中去。这样允许您创建一个任意复杂的数据结构,并将它作为一个指针传送给需要在这个数据结构上进行操作的线程。当其地址传入给 pthread_create()
的函数结束后,该线程也就结束了,而其他线程会继续运行。
对于多线程程序来说一个显而易见的问题是如何干净彻底地终止该程序(同样,我们也可以简单地用 Ctrl-C
来终止它)。对于单线程程序来说,我们很容易知道是如何终止的:当用户退出时程序就退出了。但是对于连接了四个用户的程序,应该何时退出呢?答案明显是“在最后一个用户退出后”。但是如何确定最后一个用户已经退出呢?一种解决方法是增加一个变量,每创建一个新的线程该变量就加 1,每终止一个线程该变量就减 1,当该变量再次为零时,就关闭整个进程。
这个方法听起来不错,然而它同时也存在会使程序崩溃的危险。
|
竞态条件和互斥
可以想像一下,如果在一个线程正在创建的同时另一线程正在退出,那么会发生什么情况呢?如果线程调度器正巧在它们之间切换,程序会莫名其妙地关闭。线程 1 正在执行 i = i + 1;
这样的代码,线程 2 则在执行 i = i - 1;
这样的代码。为了讨论的方便,假定变量 i 的初始值是 2。
线程 1:取出 i 的值(2)。 |
啊呀!
我们这里遇到的情况叫做竞态条件(race condition),是一种出错概率非常小的条件,意味着您只有非常快速或者非常运气不好才会遇到这种情况。竞态条件在几百万次运行中也很少遇到一次,所以很难调试出来。
我们需要采取一些方法避免线程 1 和线程 2 出现上述情况;这些方法要保证线程 1 “在完成对 i 的操作前不允许其他线程对 i 操作”。可以进行许多有趣的尝试去发现一个合适的方法;这个方法要保证两个线程不会冲突。您可以利用现有的机制自己编制代码去尝试,从中可以体会到更多的乐趣。
下一个要理解的概念就是 互斥(mutex)。互斥量(mutex 是 MUTual EXclusion 的缩写)是避免线程间相互交叠的一种方法。可以把它想像成一个惟一的物体,必须把它收藏好,但是只有别人都不占有它时您才可以占有它,在您主动放弃它之前也没有人可以占有它。占有这个惟一物体的过程就叫做锁定或者获得互斥量。不同的人学到的对这这件事的叫法不同,所以当您和别人谈到这件事时别人可能会用不同的词来表达。POSIX 线程接口沿用相当一致的术语,所以称为“锁定”。
创建和使用互斥量的过程比仅仅是开始一个线程的过程要稍微复杂一些。互斥量对象必须先被声明;声明后还必须初始化。做完这些之后,才可以被加锁和解锁。
|
互斥代码:第一个例子
在浏览器(或者展开的 pth 目录)中查看 dthread2.c。
为了方便,对 pthread_create()
的调用被单独放到一个新的称为 spawn()
的例程中。这样做使增加互斥代码时只需要在一个子程序中进行改动。这个例程做了一些新的工作;它锁定一个叫做 count_mutex
的互斥量。之后它创建一个新的线程同时增量 threadcount
;在完成之后,它解锁互斥量。当一个线程准备终止时,它再次锁定互斥量,减量 threadcount
,然后解锁互斥量。如果互斥量 threadcount
的值减小到了零,我们知道这时已经没有线程在运行了,该退出程序了。然后这种说法并不完全正确;这时仍然有一个线程在运行,这个线程是进程初始运行时建立的线程。
您可能会注意到对一个叫做 pthread_mutex_init()
的函数的调用。这个函数对互斥量运行环境进行初始化,这个过程对互斥量正常工作是必需的。当不再使用互斥量时,可以调用 pthread_mutex_destroy()
来释放在初始化过程中分配的资源。有些实现不分配或释放任何资源;只是使用了 API。如果您不进行这些调用,总有您最不期望出现的那一刻,一个生产系统将会使用一种新的依赖于这些调用的实现 —— 您会在临晨 3 点钟发现它带来的问题。
在 spawn()
中将锁定互斥量的代码紧跟在改变 threadcount
的后面似乎是合理的。听起来也不错,不幸的是,这样做实际会引入一个令人气馁的 bug。这种程序会非常频繁地输出一个提示,然后挂起不动。听起来有些令人不解?记住,一旦 pthread_create()
被调用,新的线程就开始执行了。所以事件发生的顺序看起来是以下面的顺利进行的:
主线程:调用 pthread_create()。
新的子线程:输出提示信息。
旧的子线程:退出,减量 threadcount 到 0。
主线程:锁定互斥量并增量 threadcount。
当然,按这个顺序最后一步永远不会执行。
如果您将调用 pthread_create()
前面改变 threadcount
值的代码去掉,那么互斥量代码中间就只剩下减小计数值的语句了。示例程序不是按照这样写的,这样做只是为了增加例子的趣味性。
|
解开死锁
这篇文章的前面曾经提到过原始程序中的一个微妙的潜在的竞态条件。现在,谜底已经揭穿。这个不易发现的竞态条件是 rand()
存在内部状态。如果在两个线程交叠时调用 rand()
,它就可能返回一个错误的随机数。对这个程序来说,这可能不是什么大问题,但对于一个以随机数的再现性为基础的正规的模拟程序来说,这可就是一个大问题了。
所以,让我们更进一步,在产生随机数的地方加一个互斥量。这样,我们就容易地解决了这个竞态条件问题。
浏览 dthread3.c,或者在 pth 目录下打开这个文件。
不幸的是,这里仍旧存在另一个潜在的问题,叫做死锁。当两个(或以上)线程相互等待别的线程时就会出现死锁。想像这儿有两个互斥量,我们分别称它们为 count_mutex
和 rand_mutex
。现在,有两个线程需要使用这两个互斥量。线程 1 的活动如下:
mutex_lock(&count_mutex); |
而线程 2 却是以另外的顺序执行这些语句:
mutex_lock(&rand_mutex); |
这时就会发生死锁等待。如果这两个线程以上面的顺序同时开始执行,它们同时开始执行锁定:
Thread 1: mutex_lock(&count_mutex); |
接下来会发生什么?如果线程 1 要运行,它就要锁定 rand_mutex
,可是这个互斥量已经被线程 2 阻塞了。如果线程 2 要运行,它就要锁定 count_mutex
,而这个互斥量已经被线程 1 占有了。这种情况可以引用一则杜撰的 Texas 法规来形容:“当两列火车在十字路口相遇时,它们都会停止前进,都等待对方开走后才能前进”。
像这样的问题,一个简单的解决办法是保证以相同的顺序获得互斥量。相似地,使每列火车安全通过的一个简单办法是始终对火车保持控制。在实际程序中,使引起死锁的调用都整齐地排队进行不太可能,即使在我们的简单的示例程序中(如果正确安排调用的顺序,完全可以避免死锁),对互斥量的调用也不是完全相邻的。
现在,将注意力转到下一个例子, dthread4。
这个版本的程序演示了一个产生死锁的一般来源:程序员的错误。
这个程序允许在一行上有多个规范,同时也限制骰子是角色游戏中常见的普通的多面骰子。这个坏程序的开发者起初的想法是好的,即只在真正需要使用之前才锁定这些互斥量,但是他却直到运行结束才解锁。因此,如果用户输入“2d6 2d8”,程序会锁定六面的骰子,投掷两次,然后锁定八面的骰子,投掷两次。只有等到所有的投掷过程全部结束,才将所有骰子解锁。
与以前版本不同的是,这个版本很容易进入死锁状态。如果您愿意的话,设想一下两个用户同时请求掷骰子,一个请求“2d6 2d8”,另一个请求“2d8 2d6”。会发生什么情况呢?
线程 1:锁定六面的骰子;投掷。 |
“聪明的”解决方案实际上是根本没有解决方案。如果投骰子的人只要一投完就马上就释放他拥有的骰子,就不会出现这个问题。
从中我们得到的第一个教训是,您可以深究模拟情况;坦白地说,锁定单个骰子的存取愚蠢的。然而,第二个教训是,不可能看到代码里面的死锁。应该锁定哪些互斥量取决于仅在运行时可用的数据,这是问题的关键所在。
如果您想实际体会一下这个 bug,试着使用 -S
选项,这样程序运行时您就有足够的时间在不同的终端间切换并且进行观察。现在,您打算怎么改正?为了讨论的方便,假设有必要锁定单个的骰子。您是怎么做的呢? dthread5.c 中是一个天真的解决方案。到了这儿,您就可以回到前面给每个骰子加一个随机数产生器。那些好的游戏者明白骰子投出来的结果好坏各半,您不会浪费掷出来的好点数,对吧?
死锁也会发生在单独的线程中。缺省使用的互斥量是“快速”的,这种互斥量有一个很大的优点,如果您试图去锁定一个您已经锁定的互斥量,这时就会产生死锁。如果可能,在设计程序时决不要锁定一个已经锁定的互斥量。另外,您还可以使用“递归”类型的互斥量,这种互斥量允许对同一个互斥量锁定多次。还有一种互斥量是用于检测一般错误的,如对一个已经解锁的互斥量再次解锁。注意,递归互斥量不能帮您解决程序中实际存在的锁定 bug。下面的代码段是从早一些版本的 dthread3.c 中摘出来的:
int roll_die(int n) { pthread_mutex_lock(&rand_mutex); return rand() % n + 1; pthread_mutex_unlock(&rand_mutex); } |
看看您能不能比我更快发现 bug(我用了大约 5 分钟)。为了守信,试着在早上 4:30 开始查错。您可以在本文结尾处的 侧栏中找到答案。
对死锁的全面讨论超出了本文的范围,但现在您知道应该去查什么资料了,而且可以在 参考资料一节的参考资料列表中找到更多的关于互斥量和死锁的资料。
|
条件变量
条件变量是另一种有趣的变量,条件变量允许在条件不满足时阻塞线程,等条件为真时再唤醒该线程。函数 pthread_cond_wait()
主要就是用于阻塞线程的,它有两个参数;第一个是一个指向条件变量的指针,第二个是一个锁定了的互斥量。条件变量同互斥量一样必须使用 API 调用对其初始化,这个 API 调用就是 pthread_cond_init()
。当不再使用条件变量时,应该调用 pthread_cond_destroy()
释放它在初始化时分配的资源。对于互斥量来说,这些调用在有些实现中可能并不做什么,但是您也应该调用它们。
当 pthread_cond_wait()
被调用后,它解锁互斥量并停止线程的执行。在别的线程唤醒它之前它会一直保持暂停状态。这些操作是“原子操作”;它们总是一起执行,没有任何其他线程在它们之间执行。它们执行完之后,其他线程才开始运行。如果另一个线程对一个条件变量调用 pthread_cond_signal()
,那么那个等待这个条件而被阻塞的线程就会被唤醒。如果另一个线程对这个条件变量调用 pthread_cond_broadcast()
,那么所有等待这个条件而被阻塞的线程都会被唤醒。
最后,当一个线程从调用 pthread_cond_wait()
而被唤醒时,要做的第一件事就是重新锁定它在最初调用时解锁的那个互斥量。这个操作要消耗一定的时间,实际上,这个时间太长了,可能在进行这项操作的同时,线程所等待的条件变量的值在这期间已经改变了。比如,一个正在等待货物被加入到链表中的线程,当它被唤醒时可能发现链表是空的。在有些实现中,线程可能偶尔在没有信号送到条件变量的情况下醒过来。线程编程不一定总是精确的科学,所以在编程时要始终注意这一点。
|
更多内容
当然,以上内容仅仅是 POSIX 线程 API 的一点皮毛。有些用户可能发现需要使用 pthread_join()
调用,这个函数可以使一个线程处于等待状态,直到另一个线程执行完成。这里有些可以设置的属性,以便完成对调度的控制。在 Web 上有许多关于 POSIX 线程的参考资料。记得要阅读使用手册。您可以使用命令 apropos pthread
或 man -k pthread
得到与 pthread 相关的使用手册。
|
参考资料
pthread_create
、 pthread_join
、 pthread_mutex_init
和 pthread_cond_init
的手册页是非常有用的。您可以在终端窗口中查看它们,或者从 Linux Man Pages Online找到它们。 关于作者
|
Peter Seebach 曾经有过十多年的 C 语言编程经验,但是他对其中大多数代码不是非常认可。他对 C 编程语言持中立态度,并且特别指出关于 pthreads API 的问题不在主题讨论范围之内,您通过 [email protected] 与他联系。 |