到目前为止,我们已经看到了OS执行的基本抽象的发展。我们已经看到了如何采用单个物理CPU并将其转变为多个虚拟CPU,从而使人们幻想同时运行多个程序。我们还看到了如何为每个进程创建大型私有虚拟内存的错觉。当操作系统确实在物理内存(有时是磁盘)之间秘密地复用地址空间时,地址空间的这种抽象使每个程序的行为就好像它具有自己的内存一样。
在本说明中,我们为单个正在运行的进程引入了新的抽象:线程的抽象。多线程程序具有一个以上的执行点(例如,多台PC,每个PC都具有一个执行点),而不是我们对程序中的单个执行点(即从中获取并执行指令的单个PC)的经典观点。从中获取并执行)。也许另一种思考方式是,每个线程都非常像一个单独的进程,只是有一个区别:它们共享相同的地址空间,因此可以访问相同的数据。
因此,单线程的状态与进程的状态非常相似。它具有一个程序计数器(PC),用于跟踪程序从何处获取指令。每个线程都有自己的专用寄存器集,用于计算。因此,如果在一个处理器上运行着两个线程,则从运行一个(T1)切换到运行另一个(T2)时,必须进行上下文切换。上下文之间的切换
线程与进程之间的上下文切换非常相似,因为在运行T2之前必须保存T1的寄存器状态并恢复T2的寄存器状态。通过流程,我们将状态保存到了流程控制块(PCB)。现在,我们需要一个或多个线程控制块(TCB)来存储进程中每个线程的状态。但是,与进程相比,上下文切换是我们在线程之间执行的,这是一个主要区别:地址空间保持不变(即,无需切换我们正在使用的页表)。
线程和进程之间的另一个主要区别在于堆栈。在经典进程的地址空间的简单模型(现在可以称为单线程进程)中,有一个堆栈,通常位于地址空间的底部(图26.1,左)。
但是,在多线程进程中,每个线程都独立运行,并且当然可以调用各种例程来执行其正在执行的任何工作。而不是地址空间中的单个堆栈,每个线程将有一个堆栈。假设我们有一个多线程进程,其中有两个线程。结果地址空间看起来有所不同(右图26.1)。
在此图中,您可以看到两个堆栈分布在整个进程的地址空间中。因此,我们放在堆栈上的所有堆栈分配变量,参数,返回值和其他内容都将放置在有时称为线程局部存储的位置,即相关线程的堆栈中。
您可能还会注意到,这是如何破坏我们美丽的地址空间布局的。 以前,堆栈(stack)和堆(heap)可以独立增长,并且只有在地址空间不足时才会出现问题。 在这里,我们再也不会遇到这样的情况了。 幸运的是,这通常是可以的,因为堆栈通常不必很大(例外是在大量使用递归的程序中)。
在详细介绍线程以及编写多线程程序可能遇到的一些问题之前,让我们首先回答一个更简单的问题。 为什么要完全使用线程?
事实证明,应该使用线程至少有两个主要原因。第一个很简单:并行性parallelism。 想象一下,您正在编写一个对非常大的数组执行操作的程序,例如,将两个大数组加在一起,或者将数组中每个元素的值增加一定量。 如果仅在单个处理器上运行,则任务很简单:只需执行每个操作即可完成。 但是,如果要在具有多个处理器的系统上执行程序,则有可能通过使用处理器分别执行部分工作来大大加快此过程。将您的标准单线程(single-threaded)程序转换为可以在多个CPU上进行此类工作的程序的任务称为并行化,而使用每个CPU的线程来执行此工作是使程序在现代硬件上更快运行的自然而典型的方法。
第二个原因更加微妙:避免由于I / O缓慢而阻塞程序进度。想象一下,您正在编写一个执行不同类型I / O的程序:等待发送或接收消息,完成显式磁盘I / O或什至(隐式)完成页面错误。您的程序可能希望执行其他操作,而不是等待,包括利用CPU执行计算,甚至发出其他I / O请求。使用线程是避免卡住的自然方法。当程序中的一个线程等待(即被阻塞等待I / O)时,CPU调度程序可以切换到其他线程,这些线程可以运行并可以执行一些有用的操作。线程使I / O与单个程序中的其他活动重叠(overlap),就像多程序(multi programming)对跨程序的进程所做的一样;结果,许多现代的基于服务器的应用程序(Web服务器,数据库管理系统等)在其实现中都使用了线程。
当然,在上述两种情况下,都可以使用多个进程而不是线程。但是,线程共享一个地址空间,因此很容易共享数据,因此在构造这些类型的程序时自然是一个选择。对于逻辑上独立的任务,对于几乎不需要共享内存中的数据结构的任务,进程是一个更合理的选择。
让我们来探讨一些细节。 假设我们要运行一个创建两个线程的程序,每个线程执行一些独立的工作,在本例中为打印“ A”或“ B”。 代码如图26.2(第4页)所示。
主程序创建两个线程,每个线程将运行函数 mythread(),尽管它们具有不同的参数(字符串A或B)。 创建线程后,它可能立即开始运行(取决于调度程序的异想天开); 或者,可以将其置于“就绪”但不处于“运行”状态,因此尚未运行。 当然,在多处理器上,线程甚至可以同时运行,但现在不用担心这种可能性。
Figure 26.2: Simple Thread Creation Code (t0.c)
1 #include
2 #include
3 #include
4 #include "common.h"
5 #include "common_threads.h"
6
7 void *mythread(void *arg) {
8 printf("%s\n", (char *) arg);
9 return NULL;
10 }
11
12 int
13 main(int argc, char *argv[]) {
14 pthread_t p1, p2;
15 int rc;
16 printf("main: begin\n");
17 Pthread_create(&p1, NULL, mythread, "A");
18 Pthread_create(&p2, NULL, mythread, "B");
19 // join waits for the threads to finish等待线程完成
20 Pthread_join(p1, NULL);
21 Pthread_join(p2, NULL);
22 printf("main: end\n");
23 return 0;
24 }
创建两个线程(分别称为T1和T2)后,主线程调用 pthread join(),它等待特定线程完成。它执行两次,从而确保T1和T2在最终允许主线程再次运行之前可以运行并完成;如果这样做,它将打印“ main:end”并退出。总体而言,在此运行期间使用了三个线程:主线程T1和T2。
让我们检查一下这个小程序的可能执行顺序。在执行图(第5页的图26.3)中,时间沿向下的方向增加,并且每一列都显示不同线程(主线程或线程1或线程2)正在运行的时间。
但是请注意,此排序不是唯一可能的排序。实际上,给定一系列指令,取决于调度程序决定在给定点运行哪个线程。例如,一旦创建线程,它可能立即运行,这将导致执行图26.4(第5页)中所示的操作。
如果说调度程序决定先运行线程2(即使线程1是较早创建的),我们甚至还可以看到在“ A”之前印有“ B”。没有理由假定首先创建的线程将首先运行。图26.5(第6页)显示了此最终执行顺序,其中线程2开始在线程1之前执行任务。
如您所见,思考线程创建的一种方法是它有点像进行函数调用。 但是,系统不是先执行函数然后返回到调用方,而是为正在调用的例程创建一个新的执行线程,并且它独立于调用方运行,可能在从创建返回之前,但是可能 后来。 接下来运行的内容由OS调度程序确定,尽管调度程序可能实现了一些明智的算法,但很难知道在任何给定的时间将运行什么。
上面显示的简单线程示例对于显示线程的创建方式以及如何根据调度程序决定如何运行它们的顺序以不同的顺序运行很有用。但是,它并没有向您显示线程在访问共享数据时如何交互。
让我们想象一个简单的示例,其中两个线程希望更新全局共享变量。我们将研究的代码在图26.6(第7页)中。
以下是有关代码的一些注意事项。首先,正如史蒂文斯(Stevens)所建议的那样[SR05],我们包装线程创建和连接例程以仅在失败时退出;对于像这样简单的程序,我们至少希望注意到发生了错误(如果确实发生了),但没有这样做。关于它的任何非常聪明的事情(例如,退出)。因此,Pthread create()只需调用pthread_create() 并确保返回码为0;否则,返回0。如果不是,则**pthread_create()**仅显示一条消息并退出。
其次,我们没有使用两个单独的函数体作为工作线程,而是仅使用了一段代码,并向线程传递了一个参数(在这种情况下为字符串),这样我们就可以让每个线程在其消息之前打印不同的字母。
最后,最重要的是,我们现在可以查看每个工作人员正在尝试执行的操作:在共享变量counter中添加一个数字,然后循环执行一千万次(1e7)。因此,期望的最终结果是:20,000,000。
现在,我们编译并运行该程序,以查看其行为。有时候,一切都会如我们所料:
**prompt>** gcc -o main main.c -Wall -pthread; ./main
main: begin (counter = 0)
A: begin
B: begin
A: done
B: done
main: done with both (counter = 20000000)
Figure 26.6: Sharing Data: Uh Oh (t1.c)
1 #include
2 #include
3 #include "common.h"
4 #include "common_threads.h"
5
6 static volatile int counter = 0;
7
8 // mythread()
9 //
10 // Simply adds 1 to counter repeatedly, in a loop 循环简单地将1重复加一
11 // No, this is not how you would add 10,000,000 to 不,这不是将10,000,000加到
12 // a counter, but it shows the problem nicely. 一个计数器,但它很好地显示了问题。
13 //
14 void *mythread(void *arg) {
15 printf("%s: begin\n", (char *) arg);
16 int i;
17 for (i = 0; i < 1e7; i++) {
18 counter = counter + 1;
19 }
20 printf("%s: done\n", (char *) arg);
21 return NULL;
22 }
23
24 // main()
25 //
26 // Just launches two threads (pthread_create) 只需启动两个线程(pthread_create)
27 // and then waits for them (pthread_join) 然后等待它们(pthread_join)
28 //
29 int main(int argc, char *argv[]) {
30 pthread_t p1, p2;
31 printf("main: begin (counter = %d)\n", counter);
32 Pthread_create(&p1, NULL, mythread, "A");
33 Pthread_create(&p2, NULL, mythread, "B");
34
35 // join waits for the threads to finish join等待线程完成
36 Pthread_join(p1, NULL);
37 Pthread_join(p2, NULL);
38 printf("main: done with both (counter = %d)\n",
39 counter);
40 return 0;
41 }
不幸的是,即使我们在单个处理器上运行此代码,我们
不一定能获得理想的结果。 有时,我们得到:
prompt> ./main
main: begin (counter = 0)
A: begin
B: begin
A: done
B: done
main: done with both (counter = 19345221)
让我们再尝试一次,看看我们是否发疯了。 毕竟,如您所教,计算机不应该产生确定性deterministic的结果吗? 也许您的教授一直在骗您? (喘气)
prompt> ./main
main: begin (counter = 0)
A: begin
B: begin
A: done
B: done
main: done with both (counter = 19221041)
每次运行不仅错误,而且产生不同的结果! 还有一个大问题:为什么会这样?
TIP 提示:知道并使用您的工具
您应该始终学习有助于您编写,调试和理解计算机系统的新工具。 在这里,我们使用一种称为反汇编程序的简洁工具。 在可执行文件上运行反汇编程序时,它会向您显示组成程序的汇编指令。 例如,如果我们希望了解更新计数器的低级代码(如本例所示),则可以运行 **objdump(Linux)**来查看汇编代码:
prompt> objdump -d main
这样做会产生程序中所有指令的长清单,并整齐地标记(特别是如果您使用**-g标志编译的话),其中包括程序中的符号信息。 objdump程序只是您应该学习使用的众多工具之一。 诸如gdb的调试器,诸如valgrind或purify**的内存分析器,当然还有其他编译器本身,您应该花时间了解更多信息; 您使用工具的能力越强,您就能构建的系统越好。
要了解为什么会发生这种情况,我们必须了解编译器生成的代码序列以进行更新以进行计数。 在这种情况下,我们希望简单地在计数器counter上添加一个数字(1)。 因此,这样做的代码序列可能看起来像这样(在x86中);
mov 0x8049a1c, %eax
add $0x1, %eax
mov %eax, 0x8049a1c
本示例假定变量计数器counter位于地址0x8049a1c。 在此三指令序列中,首先使用x86 mov指令获取该地址处的内存值,并将其放入寄存器eax中。 然后,执行加法运算,将1(0x1)加到eax寄存器的内容中,最后,eax的内容存储在同一地址的内存中。
让我们想象一下,我们的两个线程之一(线程1)进入此代码区域,因此即将使计数器counter增加1。 它将计数器counter的值(假设是50开头)加载到其寄存器eax中。 因此,线程1的eax = 50。然后将1加到寄存器中。 因此eax = 51。 现在,不幸的事情发生了:定时器中断关闭了。 因此,操作系统会将当前正在运行的线程(其PC,包括eax的寄存器等)的状态保存到该线程的TCB中。
现在,更糟的事情发生了:选择运行线程2,并输入相同的代码。 它还执行第一条指令,获取计数器的值并将其放入其eax中(请记住:每个线程在运行时都有自己的专用寄存器;这些寄存器由上下文交换代码虚拟化,以保存和恢复它们)。 此时counter的值仍为50,因此线程2的eax = 50。 然后,假设线程2执行了下两条指令,将eax递增1(因此eax = 51),然后将eax的内容保存到计数器(地址0x8049a1c)中。 因此,全局变量计数器现在的值为51。
最后,发生另一个上下文切换,线程1恢复运行。 回想一下,它刚刚执行了mov和add,现在将要执行最终的mov指令。 还记得eax = 51。 因此,最终的mov指令将执行,并将该值保存到内存中。 计数器再次设置为51。
简而言之,发生的事情是这样的:递增计数器的代码已经运行了两次,但是从50开始的counter现在仅等于51。该程序的“正确”版本应该导致变量counter等于52。
让我们看一下详细的执行跟踪,以更好地了解问题。 对于本示例,假定上面的代码被加载到内存中的地址100,如以下序列所示(请注意那些曾经习惯使用类似RISC的漂亮指令集的人:x86具有可变长度指令;此mov指令占用了 5个字节的内存,并且仅添加3个):
100 mov 0x8049a1c, %eax
105 add $0x1, %eax
108 mov %eax, 0x8049a1c
基于这些假设,发生的情况如图26.7(第10页)所示。假定计数器从值50开始,并通过此示例进行跟踪以确保您了解发生了什么。
我们在这里展示的内容称为竞争条件(或更具体地说,是数据竞争):结果取决于代码的时序执行。运气不好(即上下文切换不及时发生)
点执行),我们得到错误的结果。实际上,我们每次都可能得到不同的结果。因此,我们称此结果为不确定的,而不是一个好的确定性计算(我们习惯于从计算机中使用该确定性计算),在这种情况下,未知输出将是什么,并且在各次运行之间确实可能有所不同。
由于执行此代码的多个线程可能会导致争用情况,因此我们将此代码称为关键部分。关键部分是一段代码,用于访问共享变量(或更普遍地说,是共享资源),并且不得由多个线程并发执行。
我们真正想要此代码的是所谓的互斥。此属性保证如果一个线程在关键部分内执行,则其他线程将无法执行。
顺便说一下,几乎所有这些术语都是由埃德斯·迪克斯特拉(Essger Dijkstra)创造的,埃德斯·迪克斯特拉(Essger Dijkstra)是该领域的先驱,并由于这项工作和其他工作而获得了图灵奖。请参阅他在1968年发表的关于“协作顺序过程” [D68]的论文,其中对问题的描述非常清晰。在本书的此部分中,我们将详细了解Dijkstra。
TIP 提示:使用原子操作
原子操作是构建计算机系统中最强大的基础技术之一,从计算机体系结构到并发代码(我们在这里学习的东西),再到文件系统(我们将研究的东西)
很快),数据库管理系统,甚至是分布式系统[L + 93]。
使一系列动作原子化的想法只是用“全有或全无”表述。它应该看起来像是您希望组合在一起的所有动作都发生了,或者似乎没有发生任何动作,并且没有中间状态可见。有时,将许多动作组合为一个原子动作称为事务,这个想法在数据库和事务处理领域中得到了非常详细的发展[GR92]。
在探索并发的主题中,我们将使用同步原语将短指令序列转换为执行的原子块,但是原子性的概念要比我们想象的要大得多。例如,文件系统使用诸如日志记录或copyon-write之类的技术来原子转换其磁盘状态,这对于在系统故障时正确运行至关重要。如果那没有道理,请不要担心-在以后的章节中,会的。
解决此问题的一种方法是拥有更强大的指令,只需一步即可完全完成我们需要做的一切,从而消除了不及时中断的可能性。 例如,如果我们有一个看起来像这样的超级指令怎么办:
memory-add 0x8049a1c, $0x1
假设该指令将一个值添加到内存位置,并且硬件保证其原子执行; 当指令执行时,它将根据需要执行更新。 它不能在指令中间被中断,因为这恰恰是我们从硬件获得的保证:当发生中断时,指令要么根本没有运行,要么已经完成。 没有中间状态。 硬件可以是一件美丽的事情,不是吗?
从原子上讲,在这里是指“作为一个整体”,有时我们
视为“全部或全部”。 我们想要以原子方式执行这三个指令序列:
mov 0x8049a1c, %eax
add $0x1, %eax
mov %eax, 0x8049a1c
正如我们所说,如果只有一条指令来执行此操作,则只需发出该指令即可完成。但是在一般情况下,我们不会有这样的说明。想象我们正在构建并发的B树,并希望对其进行更新。我们是否真的希望硬件支持“ B树的原子更新”指令?可能不是,至少在一个健全的指令集中。
因此,我们要做的是向硬件询问一些有用的指令,在这些指令上我们可以构建通用的所谓同步原语集。通过使用这种硬件支持,再结合操作系统的一些帮助,我们将能够构建多线程代码,以同步和受控的方式访问关键部分,从而尽管并发具有挑战性,但仍能可靠地产生正确的结果执行。太棒了吧?
这是我们将在本书的这一部分中研究的问题。这是一个奇妙而艰巨的问题,应该使您的思想受到伤害(有点)。如果不是,那么您就不理解!继续努力直到头疼;然后,您就会知道自己正朝着正确的方向前进。此时,请稍事休息;我们不希望您的头部受伤太多。
THE CRUX关键:如何支持同步
我们需要硬件提供什么支持才能构建有用的同步原语? 我们需要操作系统提供什么支持? 我们如何正确,有效地构建这些原语? 程序如何使用它们来获得期望的结果?
本章设置了并发性问题,就好像线程之间仅发生一种交互类型一样,即访问共享变量的交互类型以及对关键部分支持原子性的需求。事实证明,出现了另一种常见的交互作用,其中一个线程必须等待另一个线程完成某些操作才能继续。例如,当进程执行磁盘I / O并使其进入睡眠状态时,就会发生这种交互。当I / O完成时,需要从休眠状态唤醒该过程,以便继续进行。
因此,在接下来的章节中,我们将不仅研究如何构建对同步原语的支持以支持原子性,而且还将研究对这种支持多线程程序中常见的睡眠/唤醒交互的机制的支持。如果现在这没有意义,那就可以了!当您阅读有关条件变量的章节时,将很快。如果到那时还没有,那么那就没问题了,您应该再读一遍,直到它有意义为止。
旁白:关键的保密条款
关键部分CRITICAL SECTION,比赛条件RACE CONDITION,
不确定INDETERMINATE,相互排除MUTUAL EXCLUSION
这四个术语对于并发代码是如此重要,以至于我们认为值得花些时间明确地指出它们。 看迪克斯特拉的早期作品[D65,D68]了解更多详情。
在总结之前,您可能会遇到的一个问题是:我们为什么要在OS类中研究它? “历史”是一句话的答案; 操作系统是第一个并发程序,并且创建了许多技术供操作系统使用。 后来,对于多线程进程,应用程序程序员还必须考虑这些事情。
例如,假设有两个进程正在运行。
假设他们都调用**write()**写入文件,并且都希望
将数据追加到文件中(即,将数据添加到文件末尾,从而增加其长度)。为此,双方都必须分配一个新块,在该块所在的文件的inode中记录该文件,并更改文件的大小以反映新的更大的大小(除其他事项外,我们将在这本书的第三部分)。由于随时可能发生中断,因此更新这些共享结构(例如,用于分配的位图或文件的索引节点 inode)的代码是关键部分;因此,操作系统设计师从引入中断的一开始,就不得不担心OS如何更新内部结构。不及时的中断会导致上述所有问题。毫不奇怪,必须使用适当的同步原语仔细地访问页表,进程列表,文件系统结构以及几乎每个内核数据结构,以使其正常工作。
该程序x86.py允许您查看不同的线程交织如何导致或避免竞争状况。 有关该程序如何工作的详细信息,请参见自述文件,然后回答以下问题。
Questions
我们来看一个简单的程序“ loop.s”。 首先,只需阅读并理解它。 然后,使用以下参数运行它(./x86.py -p loop.s -t 1 -i 100 -R dx)这将指定一个线程,每100条指令一个中断以及对寄存器%dx的跟踪。 运行期间%dx将是什么? 使用-c标志检查您的答案; 右边的指令运行后,左边的答案将显示寄存器的值(或存储器值)。
相同的代码,不同的标志:(./x86.py -p loop.s -t 2 -i 100
-a dx = 3,dx = 3 -R dx)这指定两个线程,并将每个%dx初始化为3。%dx将看到哪些值? 使用-c运行以进行检查。 是否存在多个线程会影响您的计算? 此代码中是否存在竞赛race?
运行此命令:./x86.py -p loop.s -t 2 -i 3 -r -a dx = 3,dx = 3 -R dx这使中断间隔小/随机; 使用不同 种子(-s)看到不同的交错。 请问中断频率改变什么?
现在,使用另一个程序looping-race-nolock.s,该程序访问位于地址2000的共享变量。 我们将其称为变量值。 用一个线程运行它以确认您的理解:./x86.py -p looping-race-nolock.s -t 1 -M 2000在整个运行过程中,值是什么(即,内存地址2000)? 使用-c进行检查。
运行多个迭代/线程:./x86.py -p looping-race-nolock.s -t 2 -a bx = 3 -M 2000为什么 每个线程循环三遍? value的最终值是多少?
以随机中断间隔运行:./x86.py -p looping-race-nolock.s -t 2 -M 2000 -i 4 -r -s 0与
不同的种子(-s 1,-s 2,等等),您能否通过看一下交错的线程来判断value的最终值是多少? 中断时间是否重要? 在哪里可以安全发生? 哪里不行 换句话说,关键部分到底在哪里?
现在检查固定的中断间隔:./x86.py -p
looping-race-nolock.s -a bx = 1 -t 2 -M 2000 -i 1共享变量值的最终值是什么?更改-i 2,-i 3等时该怎么办?程序针对哪个中断间隔给出“正确”答案?
以相同的方式运行更多循环(例如,设置-a bx = 100)。哪些中断间隔(-i)会导致正确的结果?哪个间隔是令人惊讶的?
最后一个程序:wait-for-me.s。 运行:./x86.py -p
wait-for-me.s -a ax = 1,ax = 0 -R ax -M 2000这将设置
对于线程0,%ax注册为1,对于线程1,%ax注册为0,并监视%ax和内存位置2000。代码应如何表现? 线程如何使用位置2000处的值? 它的最终价值是多少?
现在切换输入:./x86.py -p wait-for-me.s -a ax = 0,ax = 1 -R ax -M 2000线程如何表现? 线程0在做什么? 更改中断间隔(例如-i 1000或使用随机间隔)将如何改变跟踪结果? 程序是否有效使用CPU?