您的位置:首页 > 理论基础

深入理解计算机系统:并发编程

2016-12-14 21:58 357 查看
如果逻辑控制流在时间上是重叠的,那么它们就是并发的。应用级并发可以发生在:

访问慢速I/O设备。当一个应用正在等待来自慢速I/O设备(例如磁盘)的数据到达时,内核会运行其他进程,使CPU保持繁忙。这是通过交替执行I/O请求和其他有用的工作来使用并发。

与人交互。用户希望计算机有同时执行多个任务的能力。每次用户请求某种操作(如单击鼠标)时,一个独立的并发逻辑流被创建来执行这个操作。

通过推迟工作来降低延迟。

服务多个网络客户端。我们期望服务器每秒为成百上千的客户端提供服务,并发服务器为每个客户端创建一个单独的逻辑流。

在多核机器上进行并行计算。被划分成并发流的应用程序通常在多核机器上比在单处理器上运行得快,因为这些流会并行执行,而不是交错执行。

现代操作系统提供了三种构造并发程序的方法:

进程。每个逻辑流都是一个进程,由内核来调度和维护。

I/O多路复用。在这种形式中,应用程序在一个进程的上下文中显式地调度它们自己的逻辑流。逻辑流被模型化为状态机。因为程序是一个单独的进程,所以所有的流都共享同一个地址空间。

线程。线程是运行在单一进程上下文中的逻辑流,由内核进行调度。

基于进程的并发编程

构造并发编程最简单的方法就是用进程,使用那些大家都很熟悉的函数,像fork、exec和waitpid。

步骤:

1)服务器监听一个监听描述符上的连接请求。

2)服务器接受了客户端1的连接请求,并返回一个已连接描述符。

3)在接受了连接请求之后,服务器派生一个子进程,这个子进程获得服务器描述符表的完整拷贝。子进程关闭它的拷贝中的监听描述符3,而父进程关闭它的已连接描述符4的拷贝,因为不再需要这些描述符了。

4)子进程正忙于为客户端提供服务,父进程继续监听新的请求。

注意:子进程关闭监听描述符和父进程关闭已连接描述符是很重要的,因为父子进程共用同一文件表,文件表中的引用计数会增加,只有当引用计数减为0时,文件描述符才会真正关闭。所以,如果父子进程不关闭不用的描述符,将永远不会释放这些描述符,最终将引起存储器泄漏而最终消耗尽可以的存储器,使系统崩溃。

使用进程并发编程要注意的问题:

首先,通常服务器会运行很长的时间,所以我们必须要包括一个SIGCHLD处理程序,来回收僵死子进程的资源。因为当SIGCHLD处理程序执行时,SIGCHLD信号是阻塞的,而Unix信号是不排队的,所以SIGCHLD处理程序必须准备好回收多个僵死子进程的资源。

其次,子进程必须关闭它们各自的connfd拷贝。就像我们已经提到过的,这对父进程而言尤为重要,它必须关闭它的已连接描述符,以避免存储器泄漏。

最后,因为套接字的文件表表项中的引用计数,直到父子进程的connfd都关闭了,到客户端的连接才会终止。

对于父子进程间共享状态信息,进程有一个非常清晰的模型:共享文件表,但是不共享用户地址空间。进程有独立的地址空间既是优点也是缺点。这样一来,一个进程不可能不小心覆盖另一个进程的虚拟存储器,这就消除了许多令人迷惑的错误——这是一个明显的优点。

另一方面,独立的地址空间使得进程共享状态信息变得更加困难。为了共享信息,它们必须使用显式的IPC(进程间通信)机制。基于进程的设计的另一个缺点是,它们往往比较慢,因为进程控制和IPC的开销很高。

基于I/O多路复用的并发编程

假设要求编写一个echo服务器,它也能对用户从标准输入键入的交互命令做出响应。此时服务器必须响应两个互相独立的I/O事件:1)网络客户端发起的连接请求 2)用户在键盘上键入的命令 ,解决的办法是I/O多路复用技术。基本思想是使用select函数,要求内核挂起进程,只有在一个或多个I/O事件发生后,才将控制返回给应用程序。

可以使用select、poll和epoll来实现I/O复用。

I/O多路复用技术的优劣:

1)使用事件驱动编程,这样比基于进程的设计给了程序更多的对程序行为的控制。

2)一个基于I/O多路复用的事件驱动服务器是运行在单一进程上下文中的,因此每个逻辑流都访问该进程的全部地址空间。这使得在流之间共享数据变得很容易。一个与作为单进程运行相关的优点是,你可以利用熟悉的调试工具,例如GDB来调试你的并发服务器,就像对顺序程序那样。最后,事件驱动设计常常比基于进程的设计要高效很多,因为它们不需要进程上下文切换来调度新的流。

缺点:

事件驱动设计的一个明显缺点就是编码复杂。我们的事件驱动并发服务器需要的代码比基于进程的服务器多三倍。不幸的是,随着并发粒度的减小,复杂性还会上升。这里的粒度是指每个逻辑流每个时间片执行的指令数量

基于事件的设计的另一重大缺点是它们不能充分利用多核处理器。

基于线程的并发编程

在使用进程并发编程中,我们为每个流使用了单独的进程。内核会自动调用每个进程。每个进程有它自己的私有地址空间,这使得流共享数据很困难。在使用I/O多路复用的并发编程中,我们创建了自己的逻辑流,并利用I/O多路复用来显式地调度流。因为只有一个进程,所有的流共享整个地址空间。而基于线程的方法,是这两种方法的混合。

线程就是运行在进程上下文的逻辑流。线程由内核自动调度。每个线程都有它自己的线程上下文,包括一个唯一的整数线程ID、栈、栈指针、程序计数器、通用目的寄存器和条件码。所有的运行在一个进程里的线程共享该进程的整个虚拟地址空间。

基于线程的逻辑流结合了基于线程和基于I/O多路复用的流的特性。同进程一样,线程由内核自动调度,并且内核通过一个整数ID来标识线程。同基于I/O多路复用的流一样,多个线程运行在单一进程的上下文中,因此共享这个线程虚拟地址空间的整个内容,包括它的代码、数据、堆、共享库和打开的文件。

线程执行模型

多线程的执行模型在某些方面和多进程的执行模型是相似的。每个进程开始生命周期时都是单一线程,这个线程是主线程。在某一时刻,主线程创建一个对等线程,从这个时间点开始,两个线程就并发地运行。最后,因为主线程执行一个慢速系统调用,例如read和sleep,或者因为它被系统的间隔计时器中断,控制就会通过上下文切换到对等线程。对等线程会执行一段时间,然后控制传递回主线程,依次类推。

在一些重要的方面,线程执行是不同于进程的。因为一个线程的上下文要比一个进程的上下文小很多,线程的上下文切换要比进程的上下文切换快得多。另一个不同就是线程不像进程那样,不是按照严格的父子层次来组织的。和一个进程相关的线程组成一个对等(线程)池,独立于其他线程创建的线程。主线程和其他线程的区别仅在于它总是进程中第一个运行的线程。对等(线程)池概念的主要影响是,一个线程可以杀死它的任何对等线程,或者等待它的任意对等线程终止。另外,每个对等线程都能读写相同的共享数据。



多线程程序中的共享变量

从一个程序员的角度来看,线程很有吸引力的一个方面就是多个线程很容易共享相同的程序变量。然而,这种共享也是很棘手的。为了编写正确的线程化程序,我们必须对所谓的共享以及它是如何工作的有很清楚的了解。

为了理解变量是否是共享的,有一些基本的问题要解答:

1)线程的基础存储器模型是什么?

2)根据这个模型,变量实例是如何映射到存储器的?

3)最后,有多少线程引用这些实例?

一个变量是共享的,当且仅当多个线程引用这个变量的某个实例。

线程存储器模型

一组并发线程运行在一个进程的上下文中。 每个线程都有它自己独自的线程上下文,包括线程ID、栈、栈指针、程序计数器、条件码和通用目的寄存器值。每个线程和其他线程一起共享进程上下文的剩余部分。这包括整个用户虚拟地址空间,它是由只读文本(代码)、读/写数据、堆以及所有的共享库代码和数据区域组成的。线程也共享同样的打开文件的集合。

从实际操作的角度来说,让一个线程去读或写另一个线程的寄存器值是不可能的。另一方面,任何线程都可以访问共享虚拟存储器的任意位置。如果某个线程修改了存储器的位置,那么其他每个线程最终都能在它读这个位置时发现这个变化。因此,寄存器是从来不共享的,而虚拟存储器总是共享的。

各自独立的线程栈的存储器模型不是那么整齐清楚的。这些栈被保存在虚拟地址空间的栈区域中,并且通常是被相应的线程独立地访问的。我们说通常而不是总是,是因为不同的线程栈是不对其他线程设防的。所以,如果一个线程以某种方式得到一个指向其他线程栈的指针,那么它就可以读写这个栈的任何部分。

将变量映射到存储器

线程化的C程序中变量根据它们的存储器类型被映射到虚拟存储器:

全局变量。全局变量是定义在函数之外的变量。在运行时,虚拟存储器的读/写区域只包含每个全局变量的一个实例,任何线程都可以引用。

本地自动变量。本地自动变量就是定义在函数内部但是没有static属性的变量。在运行时,每个线程的栈都包含它自己的所有本地自动变量的实例。即使当多个线程执行同一个线程例程时也是如此。

本地静态变量。本地静态变量是定义在函数内部并有static属性的变量。和全局变量一样,虚拟存储器的读/写区域只包含在程序中声明的每个本地静态变量的一个实例。

使用信号量同步线程

共享变量引入了同步错误

信号量

Dijkastra提出了基于信号量的解决同步不同执行线程问题的方法。信号量s是具有非负整数值的全局变量,有两种特殊的操作来处理(P和V):

P(s):如果s非零,那么P将s减1,并且立即返回。如果s为0,那么就挂起这个线程,直到s变为非零。

V(s):V操作将s加1。如果有任何线程阻塞在P操作等待s变为非零,那么V操作会重启这些线程中的一个,然后该线程将s减1,完成它的P操作。

P中的测试和减1是不可分割的,一旦预测s变为非零,就会将s减1,不能有中断。V的加1操作也是不可分割的,也就是加载,加1和存储信号量的过程中没有中断。

P和V的定义确保了一个正在运行的程序绝不可能进入这样一种状态,即一个正确初始化了的信号量有一个负值。这称为信号量不变性

使用信号量实现互斥

信号量提供了一种很方便地方法来确保对共享变量的互斥访问。基本思想是将每个共享变量(或者一组相关的共享变量)与一个信号量s(初始为1)联系起来,然后用P(s)和V(s)操作将相应的临界区包围起来。

以这种方式来保护共享变量的信号量叫做二元信号量。因为它的值总是0或者1。以提供互斥为目的的二元信号量常常也称为互斥锁

在互斥锁上执行P操作称为加锁,执行V操作称为解锁。对一个互斥锁加了锁但还没解锁的线程称为占用这个互斥锁,一个被用作一组可用资源的计数器的信号量称为计数信号量

P和V操作的结合创建了一组状态,叫做禁止区,从而确保了对临界区的互斥访问。

利用信号量调度共享资源

在这种场景中,一个线程用信号量操作来通知另一个线程,程序状态中的某个条件已经为真了。两个经典应用:

生产者-消费者问题



生产者和消费者共享一个有n个槽的有限缓冲区。必须保证对缓冲区的访问是互斥的;还需要调度对缓冲区的访问,即,如果缓冲区是满的(没有空的槽位),那么生产者必须等待直到有一个空的槽位为止,如果缓冲区是空的(即没有可取的项目),那么消费者必须等待直到有一个项目变为可用。

读者-写者问题

修改对象的线程叫做写者;只读对象的线程叫做读者。写着必须拥有对对象的独占访问,而读者可以和无限多个其他读者共享对象。

读者-写者问题基本分为两类:

第一类,读者优先,要求不要让读者等待,除非已经把使用对象的权限赋予了一个写者。换句话说,读者不会因为有一个写者等待而等待;

第二类,写者优先,要求一旦一个写者准备好可以写,它就会尽可能地完成它的写操作。同第一类问题不同,在一个写者后到达的读者必须等待,即使这个写者也是在等待。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: