什么是多线程?
您使用 Linux 的原因之一或许是因为它是一个多任务操作系统。 在这种情况下,您可能很清楚能够同时做多件事的实用性——也许在编辑给老板的辞职信的同时编译那个将为您带来名誉和财富的实用程序。 因此,您可能会认识到一个可以同时做多件事的独立进程的实用性。
什么时候这是个好主意呢? 多线程的一个应用是一个依赖于大量非常相似且独立的数学运算的程序——经常被引用的例子是矩阵乘法。 在本文的后面,我们将看一个简单的多线程矩阵乘法器示例。
可以从多线程中受益的另一种类型的应用程序是服务器应用程序。 每当客户端尝试连接到服务器时,都可以创建一个新线程来处理该客户端,而“监视”线程继续等待更多客户端连接。
“等等!” 我听到你喊道。“许多服务器应用程序已经像那样工作了,但它们只是 fork 另一个进程而不是启动另一个线程。”
“你说的对……”我回答道。
“不要打断,”你继续说道。“这听起来像是另一种做同样事情的方式,但除了说它是“多线程应用程序”之外,没有其他好的理由,因此你可以向那些喜欢被科学蒙蔽双眼的人提高价格。”
在这一点上,我决定忽略你更多的插话,只是解释一下。
是的,创建一个新线程与 fork 一个新进程非常相似,但存在差异。 当 fork 一个新进程时,它与创建它的父进程共享的数据相对较少; 当创建一个新线程时,它共享更多信息(例如所有全局变量和静态局部变量、打开的文件和进程 ID)。 创建所有内容的单独副本的开销使得创建新进程比创建新线程慢。 并且将控制权从一个进程传递到另一个进程(上下文切换)所花费的时间比将控制权从一个线程传递到另一个线程所需的时间更长。 此外,由于线程共享它们的数据空间,因此从一个线程向另一个线程传递信息比从一个进程向另一个进程传递信息要简单得多——虽然这确实有其自身的问题,但如果稍加注意,则不必很困难。 下面给出了一个简单的多线程服务器示例。
可以从多线程中受益的第三种类型的应用程序是类似 Doom 的游戏,其中每个计算机控制的坏人都有自己的“智能”并且可以独立行动。 程序的主要游戏部分可以在其自己的线程(或多个线程)中,可以有另一个线程处理游戏中由真人控制的每个角色,以及更多线程处理由计算机控制的每个角色。
有一个用于多线程的 POSIX 标准:1003.1c。 如果你想编写可移植的程序(在我看来这似乎不是一个坏主意),明智的做法是坚持这个标准,而不是使用不符合 POSIX 标准的库或使用 Linus 在 Linux 内核中友好提供的原始系统调用(关于这一点,我稍后会再说一点)。
本文中的示例使用了从 C 调用的 POSIX 多线程函数。 但是,某些非 POSIX 库和系统中的概念通常非常相似。 熟悉 POSIX 线程的人很容易理解 Solaris 线程,虽然 Java 线程以及 Win32 和 OS/2 API 中的多线程有点不同,但其中应该没有什么会让你吓破胆的。
多线程功能包含在 2.0 版 Linux 内核(和许多 1.3 版内核)中。clone() 系统调用创建一个新的 执行上下文,或“COE”(使用 Linus 的术语),它可以是一个新进程、一个新线程或一系列不属于这两个类别的可能性之一。fork() 系统调用实际上是对 clone() 的调用,其中特定值作为参数,而 pthread\_create() 函数调用可能是对 clone() 的调用,其中不同的值作为参数。 一些多线程库的实现使用 clone() 来提供 内核线程,但对 clone() 的进一步讨论超出了本文的范围。
线程库可以使用内核功能进行创建和调度(例如 clone())来实现,或者完全在用户空间中实现,其中库中的代码处理线程的创建和进程内线程之间的调度。 通常,用户级线程库比内核线程库更快。 另一方面,内核线程库可能更好地利用内核设施——如果内核在下一个版本中更好地利用多处理器,那么你所有的多线程程序也可能会这样。 然而,程序员不必担心库是作为用户级库、内核级库还是两者的组合来实现的; 程序的运行应该基本相同。
有许多可用于 Linux 的 POSIX 线程库,其中一些库并未尝试符合 POSIX 标准。 此外,一些 POSIX 线程库符合标准的早期草案或仅符合标准的一部分。 任何不符合最终 POSIX 标准的东西都可能在某些情况下表现出不同的行为或具有略微不同的函数调用。 这并不是说这些不是好的多线程库,但你应该意识到你正在使用什么。
本文中的所有示例均使用 Xavier Leroy 发布的 LinuxThreads 0.3 (ALPHA) 版本进行测试,该版本受 GNU 库通用公共许可证保护。 该库仍在开发中,目标是在未来的某个时候完全符合 POSIX 标准。 它是基于 clone() 的,因此它具有内核级实现的优点和缺点。
如前所述,多线程编程实际上并不是很困难。 但是,有一些方法可以让自己过得很艰难。
优秀的编程指南经常说要避免使用全局数据。 也许你从未看到过这一点的意义——特别是如果你是一个细心的程序员,并且你知道如何处理你的全局数据。 使用多线程,还有另一个原因要避免使用全局数据。 考虑列表 2中的简单代码片段。
当调用此函数时,您会期望看到打印数字“0 1 2 3”。 事实上,这就是会发生的事情。 如果您要从两个不同的线程调用此函数,您可能会期望看到两组打印的数字,每组来自一个线程。 如果两个线程在大约同一时刻调用此函数,您可能会期望看到两组数字交错,可能类似于
0 1 0 1 2 2 3 3
在这里,线程 A 调用 fn(),它开始打印数字。 当线程 A 只到“1”时,线程 B 启动并调用 fn(),线程 B 设法到达“2”,然后线程 A 再次获得控制权。 对 fn() 的调用对于线程 A 完成,线程 B 再次获得控制权并完成。
但是,这不会发生。 请让我强调一下,这不是会发生的事情。 相反,输出可能是
0 1 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14...
正在发生的事情是线程 A 调用 fn(),它打印数字直到“1”。 然后线程 B 启动并调用 fn()。 当进入 for 循环时,i 的值设置为 0。记住 i 是全局的,所以它被两个线程共享——当线程 B 更改 i 的值时,线程 A 将看到相同的更改。 计数器达到值“2”,此时线程 A 再次被赋予控制权。 当它尝试递增 i 时,它现在达到“3”,然后是“4”,此时线程 A 不打印 i 的值并准备退出函数。 当线程 B 再次获得控制权时,它打印 i 的当前值(即“4”,由线程 A 提供),将其递增到“5”,然后进行比较 i!=4。 比较不会失败(并且在很长一段时间内不会失败——直到 i 达到最大 int 值并再次循环),因此循环继续打印数字。
这听起来很可怕。 这很可怕! 而且可以通过不使用全局数据来避免这种情况。 如果 i 被声明为 fn() 的局部变量,则每个线程都会有自己的副本,你将获得预期的输出。
有时您可能会发现您需要使用全局数据,要么是因为您正在向无法重写的现有程序添加多线程功能,要么是因为您认为全局数据是正确的使用方式。 请注意,在这种情况下,我说“全局数据”时,我真正指的是 共享数据——数据可能不是程序的全局数据,但它是(或在多个线程之间)全局的。 在传统的单线程程序中,static 局部变量可能是一种有用的便利; 然而,这些实际上是全局变量,如果你不小心处理它们,它们会导致你出现难以追踪的错误。 那么你如何处理全局数据呢? 你所要做的就是确保在您将值设置为该数据的时间和您使用该值的时间之间,没有其他线程可以更改您的全局数据的值。 POSIX 线程提供了多种方法来执行线程之间的这种 同步。 最简单的是互斥锁,或互斥量。 一个线程在一段代码的开头锁定一个互斥量,并在该段代码的结尾解锁它。 持有互斥量锁的线程被称为拥有该互斥量。 当一个线程拥有该互斥量时,没有其他线程能够执行同一段代码。
考虑我们之前关于失控循环的例子。 我们可以重写它,使其仍然使用全局数据,但使用互斥量来防止两个线程同时修改同一个循环计数器。
在列表 3中,loopLock 是一个互斥变量。 互斥变量应始终在使用前初始化,可以通过调用初始化函数 pthread\_mutex\_init() 来初始化,该函数可用于设置特定于该互斥变量的属性值,也可以使用标准宏 PTHREAD\_MUTEX\_INITIALIZER,该宏使用默认值。 这些属性会影响拥有互斥量的线程的优先级和调度,或指示哪些线程可以操作互斥量(同一进程内的线程或任何可以访问互斥量驻留内存的线程)。 在这里我们只使用默认的互斥属性。
当线程调用 pthread\_mutex\_lock() 时,它尝试拥有的互斥变量将是空闲的或由另一个线程拥有的。(如果一个线程尝试锁定它已经拥有的互斥量,则结果是未定义的。不要这样做!)如果互斥量是空闲的,线程将获得该互斥量的所有权,并且 pthread\_mutex\_lock() 将返回。如果另一个线程拥有该互斥量,则调用将等待,直到互斥量变为空闲,并且可以为自己的调用线程声明所有权。然后,线程拥有该互斥量,直到它调用 pthread\_mutex\_unlock()。(同样,如果一个线程尝试解锁它尚未拥有的互斥量,则结果是未定义的。)
因此,如果多个线程尝试同时执行函数中的代码,那么只有第一个线程会拥有锁并进入循环。 任何其他线程都将坐在 pthread\_mutex\_lock() 调用处,直到第一个线程退出循环并解锁互斥量。 然后所有权将只授予等待线程中的一个,该线程将能够安全地进入循环。
因此,如果有两个线程同时调用此函数,则输出将是
0 1 2 3 0 1 2 3
正如预期的那样,每次通过循环都会在下一次开始之前完成,并且输出不会交错。
当然,这个问题有一个更优雅的解决方案:首先避免使用全局数据。 在列表 4中,循环计数器是函数的局部变量,并且没有冲突; 每次调用该函数都有其自己的 i 版本,因此每个调用该函数的线程都将拥有自己的 i 版本。
但是,两个(或更多)不同的线程可以同时在循环内部,因此输出可能会交错,正如我们最初预期的那样。 例如
0 1 0 1 2 2 3 3
如果您的应用程序不适合在同一时间在某段代码中拥有多个线程(这段代码通常被称为临界区),即使您没有共享数据,您可能仍然希望使用互斥量。
但是,请记住,临界区可能会通过强制线程等待其他线程来抵消多线程的一些优势。 最好尽可能缩短任何必要的临界区。
程序员可能需要意识到的是,正在使用的其他库是否是线程安全的。 否则,可能会发生(或更可能发生)上述问题。 一个很好的例子是每个 C 和 C++ 程序员的朋友 errno。 当系统调用由于某种原因失败时,通常被调用的函数会返回一个通用的失败值(例如 NULL 或 -1)以及失败原因的指示,该指示在 errno 中——当然,这是一个全局变量。 试想一下,如果一个线程调用 putenv(),它失败并将 errno 设置为 ENOMEM,会造成什么混乱。 然后发生上下文切换,另一个线程调用 open() 并失败,将 errno 设置为 ELOOP。 然后再次发生上下文切换; 检查来自 putenv() 的返回值,发现失败,并且混乱爆发,因为它看起来 putenv() 失败并返回了 ELOOP,这不可能。
或者考虑标准函数 strtok()。 这会将字符串分解为标记,并以两种方式工作。 在第一次调用时,您传入一个要标记化的字符串。 它将此字符串存储在静态区域中。 在后续调用中,您传入一个 NULL 字符串,并且 strtok() 通过已经存储的字符串工作。 如果两个线程同时调用 strtok(),则第一个线程的存储字符串将被第二个线程告诉 strtok() 存储的字符串覆盖。
幸运的是,现在可以使用线程安全版本的 errno 和库中标准函数的线程安全版本。 例如,看看 /usr/include/errno.h。 如果您有一组最新的头文件,您可能会发现以下代码段
#if defined(_POSIX_THREAD_SAFE_FUNCTIONS) || \ defined(_REENTRANT) extern int* __errno_location __P((void)); #define errno (*__errno_location ()) #else extern int errno; #endif
当定义了 \_POSIX\_THREAD\_SAFE\_FUNCTIONS 或 \_REENTRANT 时,errno 被重新定义为函数返回的 int,而不是通常的全局变量。
当为多线程程序编译代码时,始终使用
#define _REENTRANT
在您的代码中(或使用编译器选项 -D\_REENTRANT 来利用 errno 的线程安全宏)。
同样,最新版本的 libc 包含标准不安全函数的线程安全版本。 因此,现在有通常不安全的 strtok(),还有线程安全函数 strtok\_r()——请参阅您的手册页以获取详细信息。
所有这些,我们仍然没有创建线程! 好的,让我们纠正一下。 列表 5 是一个非常简单且完整的多线程程序示例。
当我编译(使用 cc helloworld.c -o helloworld -lpthread)并运行此程序时,我得到输出:Hello world world Hello Hello world world Hello Hello world world Hello Hello world world Hello Hello world world Hello
让我们看一下这个程序。 有一个互斥变量 printfLock; 这可能不是绝对必要的,但包含它是为了以防我们没有使用线程安全版本的 libc——为了安全起见,互斥锁被放在对 printf() 的调用周围。
main() 中有两个新的函数调用:pthread\_create() 和 pthread\_join()。 第一个函数 pthread\_create(),顾名思义,创建了一个新线程。 第一个参数指向 pthread\_t 类型的变量; 这可以用于标识新创建的线程,类似于从 fopen() 调用返回的文件句柄。 第二个参数允许您为新线程设置各种属性。 如果值为 NULL,则使用默认属性,现在这样做很好。 第三个参数是线程要执行的函数; 这始终是一个将 void* 作为参数并具有 void* 返回值的函数。 第四个参数是要作为参数 3 中定义的线程函数的参数传递的参数。
一旦 pthread\_create() 成功创建了一个新线程,作为第三个参数传递的函数将开始运行。
在此示例中,线程函数将字符串作为参数,并在退出前打印该字符串十次。 从输出中可以看出,两个线程同时执行,并且它们的输出是交错的。
pthread\_join() 函数等待指定的线程(使用线程创建的第一个参数中返回的 pthread\_t 变量标识)退出。 当线程函数返回时,或者当线程显式调用函数 pthread\_exit() 时,线程退出。
列表 6 显示了一个简单的程序,该程序将对一对固定大小的方阵执行矩阵乘法,并使用单独的线程执行结果矩阵每一列的计算。
快速回顾一下,如果您有一段时间没有进行矩阵数学运算,则两个矩阵相乘的结果由列表 1中的程序给出。 运行该程序会产生图 1 中看到的输出。
| 9 8 7 6 | | 1 2 3 4 | | 150 180 210 240 | | 6 6 4 3 | | 4 5 6 7 | | 84 102 120 138 | | 3 2 1 0 | x | 7 8 9 10 | = | 18 24 30 36 | | 0 -1 -2 -3 | | 10 11 12 13 | | -48 -54 -60 -66 |
关于这个程序,有许多值得注意的事情。
首先,只有当您拥有具有多个处理器的系统时,您才可能通过使用这样的多线程程序来获得速度提升。 为什么? 因为在单处理器系统中,一个处理器必须执行所有计算 并且 花费额外的时间在不同线程之间进行调度。 在多处理器系统中,调度程序可能能够通过在每个 CPU 上同时运行不同的线程来在多个 CPU 上运行相同的进程。
其次,它使用全局数据。 在我费了这么大劲说这是一件危险的事情之后。 而且我甚至不使用互斥量。 你很可能会感到冒犯! 但是,我想说明在什么情况下使用全局数据是安全的。(使用全局数据是否是一个好主意是另一个问题,而哲学辩论的方向不是本文的目的——我只是表明全局数据可以被使用。)为什么在这里它是安全的? 好吧,两个矩阵 mat1 和 mat2 是只读的; result 矩阵是写入的,但每个线程只写入用于存储该线程正在处理的结果列的数组的特定部分。 永远不会有任何冲突。
列表 7 显示了一种可以构建简单多线程服务器的方法。
请注意,与所有示例一样,为了保持代码的简洁和可读性,错误检查已保持在最低限度。 “真实”代码需要比这健壮得多。 始终检查手册页以获取可能的返回代码和错误值。
服务器的工作方式如下:它监听端口 12345 以服务来自客户端的请求,并且它还执行与客户端活动无关的任务(即,它每秒在标准输出上打印“服务器正在运行”)。
在线程方面,它的工作方式如下:主线程创建一个线程来侦听连接到服务器端口的客户端,然后它进入无限循环,打印“正在运行”消息,然后休眠一秒钟。“客户端侦听”线程侦听端口 12345 上的套接字。每当客户端连接到该套接字时(例如,有人键入“telnet 主机名 12345”),连接被接受,并启动另一个线程来专门处理该客户端。“客户端侦听”线程然后继续侦听更多客户端连接。“客户端处理”线程向客户端打印一条有用的消息(“键入 `X' 退出”),然后等待客户端发回消息。如果消息以“X”开头,则套接字关闭,线程退出; 否则,消息再次打印,线程再次等待来自客户端的数据。
以这种方式编写的服务器中,为每个同时连接的客户端运行一个线程。 这提供了为每个连接的客户端 fork 一个新进程的服务器的优点,但没有 fork 新进程或在进程之间切换的额外开销,并且能够在线程内轻松通信。
Martin 已经做了 10 年的软件工程师,并且自 0.12 版本以来一直在使用 Linux。 但他在几个乐队中演奏和用凯尔特结饰装饰他的公寓更有乐趣。 可以通过 marty@ehabitat.demon.co.uk 联系到他。