深入理解计算机系统结构——并发编程

  1. 云栖社区>
  2. 博客>
  3. 正文

深入理解计算机系统结构——并发编程

指尖的舞曲 2015-04-27 18:56:00 浏览425
展开阅读全文

并发编程

如果逻辑控制流在实际上重叠,那么它们就是并发的,这种常见的现象称为并发,出现在计算机系统的许多不同层面上。

应用级并发在其他情况下也是很有用的:

  • 访问慢速I/O设备。
  • 与人交互。
  • 通过推迟工作以降低延迟。
  • 服务多个网络客户端。
  • 在多核机器上进行并行计算。

使用应用级并发的应用程序称为并发程序。现代操作系统提供了三种基本的构造并发程序的方法:

  • 进程。用这种方法,每个逻辑控制流都是一个进程,由内核来调度和维护。因为进程有独立的虚拟地址空间,想要和其他流通信,控制流必须使用某种显式的进程间通信机制。
  • I/O多路复用。在这种形式的并发编程中,应用程序在一个进程的上下文中显式地调度它们自己的逻辑流。逻辑流被模型化为状态机,数据到达文件描述符后,主程序显式地从一个状态转换到另一个状态。因为程序是一个单进程,所以所有的流都共享同一地址空间。
  • 线程。线程是运行在一个单一进程上下文中的逻辑流,由内核进行调度。你可以把线程看成是其他两种方式的混合体,像进程流一样由内核进行调度,而像I/O多路复用流一样共享同一虚拟地址空间。

基于进程的并发编程

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

步骤:

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

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

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

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

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

                

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

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

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

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

缺点:

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

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

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

    1、面对困境——服务器必须响应两个互相独立的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,或者因为它被系统的间隔计时器中断,控制就会通过上下文切换到对等线程。对等线程会执行一段时间,然后控制传递回主线程,依次类推。

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

 

Posix 线程

创建线程

线程通过调用pthread_create函数来创建其他线程:

pthread_create函数创建一个新的线程,并带着一个输入变量arg,在新线程的上下文中运行线程例程f。能用attr参数来改变新创建线程的默认属性。

当pthread_create返回时,参数tid包含新创建线程的ID。新线程可以通过调用pthread_self函数来获得它自己的线程ID。

终止线程

一个线程是以下列方式之一来终止的:

  • 当顶层的线程例程返回时,线程会隐式地终止
  • 通过调用pthread_exit函数,线程会显式地终止。如果主线程调用pthread_exit,它会等待所有其他对等线程终止,然后再终止主线程和这个进程,返回值为thread_return。
  •  某个对等线程调用exit函数,则函数终止进程和所有与该进程相关的线程;
  • 另一个对等线程调用以当前ID为参数的函数ptherad_cancel来终止当前线程。

 

回收已终止线程的资源

     pthread_join函数会终止,直到线程tid终止,将线程例程返回的(void*)指针赋值为thread_return指向的位置,然后回收已终止线程占用的所有存储器资源。和wait不同,该函数只能回收指定id的线程,不能回收任意线程。

 

分离线程

在任何一个时间点上,线程是可结合的或者是分离的。一个可结合的线程能够被其他线程收回其资源和杀死。在被其他线程回收之前,它的存储器资源(例如栈)式没有被释放的。相反,一个分离的线程是不能被其他线程回收和杀死的。它的存储器资源在它终止时由系统自动释放。

默认情况下,线程被创建成可结合的。为了避免存储器泄漏,每个可结合线程都应该要么被其他线程显式地收回,要么通过调用pthread_detach函数被分离。

pthread_detach函数分离可结合线程tid。线程能够通过以pthread_self()为参数的pthread_detach调用来分离它们自己。

     初始化线程:该函数用来初始化多个线程共享的全局变量。

                 

多线程程序中的共享变量

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

为了理解变量是否是共享的,有一些基本的问题要解答:1)线程的基础存储器模型是什么?2)根据这个模型,变量实例是如何映射到存储器的?3)最后,有多少线程引用这些实例?一个变量是共享的,当且仅当多个线程引用这个变量的某个实例。

线程存储器模型:

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

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

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

将变量映射到存储器:

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

  • 全局变量。全局变量是定义在函数之外的变量。在运行时,虚拟存储器的读/写区域只包含每个全局变量的一个实例,任何线程都可以引用。
  • 本地自动变量。本地自动变量就是定义在函数内部但是没有static属性的变量。在运行时,每个线程的栈都包含它自己的所有本地自动变量的实例。即使当多个线程执行同一个线程例程时也是如此。
  • 本地静态变量。本地静态变量是定义在函数内部并有static属性的变量。和全局变量一样,虚拟存储器的读/写区域只包含在程序中声明的每个本地静态变量的一个实例。

共享变量
我们说一个变量v是共享的,当且仅当它的一个实例被一个以上的线程引用。

共享变量的同步与互斥

1)使用信号量同步线程

        共享变量引入了同步错误。

        进度图:                                                                                           轨迹线示例:                                                                    临界区(不安全区):

                   

     信号量:是用信号量解决同步问题,信号量s是具有非负整数值的全局变量,有两种特殊的操作来处理(P和V):

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

                V(s):V操作将s加1。

    使用信号量实现互斥:

                                     

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

      a)生产者——消费者问题

                                  

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

                                            

                                          

                                        

       注释:5~13行,缓冲区初始化,主要是对缓冲区结构体进行相关操作;16~19行,释放缓冲区存储空间;22~29行,生产(有空槽的话,在空槽中插入内容);32~4行,消费(去除某个槽中的内容,使该槽为空)

      b)读者——写者问题

        修改对象的线程叫做写者;只读对象的线程叫做读者。写着必须拥有对对象的独占访问,而读者可以和无限多个其他读者共享对象。读者——写者问题基本分为两类:第一类,读者优先,要求不要让读者等待,除非已经把使用对象的权限赋予了一个写者。换句话说,读者不会因为有一个写者等待而等待;第二类,写者优先,要求一定能写者准备好可以写,它就会尽可能地完成它的写操作。同第一类问题不同,在一个写者后到达的读者必须等待,即使这个写者也是在等待。以下程序给出了第一类读者——写者问题的解答:

                                          

      注释:信号量w控制对访问共享对象的临界区的访问。信号量mutex保护对共享变量readcnt的访问,readcnt统计当前临界区的读者数量。每当一个写者进入临界区,它就对互斥锁w加锁,每当它离开临界区时,对w解锁,这就保证了任意时刻临界区最多有一个写者;另一方面,只有第一个进入临界区的读者对w加锁,而只有最后一个离开临界区的读者对w解锁。

      综合:基于预线程的并发服务器之前介绍的基于线程的并发服务器,需要为每个客户端新建一个新线程,导致不小的代价。一个基于预线程化的服务器通过使用如下图所示的生产者——消费者模型来降低这种开销。服务器是由一个主线程和一组工作组线程构成的。主线程不断地接受来自客户端的连接请求,并将得到的连接描述符放在一个有限缓冲区中。每一个工作组线程反复地从共享缓冲区中取出描述符,为客户端服务,然后等待下一个描述符。

                                   

      程序示例如下图:

                                                               

                                                               

                                                               

                                                             

                                                            

       注释:26~27行,产生工作组线程;29~32行,接受客户端的连接请求,并把这些描述符放到缓冲区;35~43行,每个线程所要完成的工作;19行,初始化线程共享的全局变量。初始化有两种方式,一种是它要求主线程显示地调用一个初始化函数;第二种是,在此显示的,当第一次有某个线程调用echo_cnt函数时,使用pthread_once函数去调用初始化函数。

其他并发问题

1)线程安全

当用线程编写程序时,我们必须小心地编写那些具有称为线程安全性属性的函数。一个函数被称为线程安全的,当且仅当被多个并发线程反复地调用时,它会一直产生正确的结果。如果一个函数不是线程安全的,我们就说它是线程不安全的。

我们能够定义出四个(不想交的)线程不安全函数类:

第一类:不保护共享变量的函数。

第二类:保持跨越多个调用的状态的函数。一个伪随机数生成器是这类线程不安全函数的简单例子。rand函数是线程不安全的,因为档期调用的结果依赖于前次调用的中间结果。当调用srand为rand设置了一个终止后,我们从一个但线程中反复地调用rand,能够预期得到一个可重复的随机数字序列。

第三类:返回指向静态变量的指针的函数。某些函数,例如ctime和gethostbyname,将计算结果放在一个static变量中,然后返回一个指向这个变量的指针。如果我们从并发线程中调用这些函数,那么将可能发生灾难,因为正在被一个线程使用的结果会被另一个线程悄悄地覆盖了。

有两种方法来处理这类线程不安全函数。一种选择是重写函数,使得调用者传递存放结果的变量的地址。这就消除了所有共享数据,但是它要求程序员能够修改函数的源代码。

如果线程不安全是难以修改或不可能修改的,那么另外一种选择是使用加锁-拷贝技术。基本思想是将线程不安全函数与互斥锁联系起来,在每一个调用位置,对互斥锁加锁,调用线程不安全函数,将函数返回的结果拷贝到一个私有的存储器位置,然后对互斥锁解锁。为了尽可能减少对调用者的修改,你应该定义一个线程安全的包装函数,它执行加锁-拷贝,然后通过调用这个包装函数来取代对线程不安全函数的调用。

第四类:调用线程不安全函数的函数。如果函数f调用线程不安全函数g,那么f就是线程不安全的吗?不一定。如果g是第二类资源,即依赖于跨越多次调用的状态,那么f也是线程不安全的,而且除了重写g以为,没有办法。然而,如果g是第一类或第三类函数,那么只要你用一个互斥锁保护调用位置和任何得到的共享数据,f仍然可能是线程安全的。

2)可重入性

有一类重要的线程安全函数,叫做可重入函数,其特点在于它们具有这样一种属性:当它们被多个线程调用时,不会引用共享数据。尽管线程安全和可重入有时会被用做同义词,但是它们之间还是有清晰的技术差别的。下图表示了可重入函数、线程安全函数和线程不安全函数之间的集合关系。可重入函数集合是线程安全函数的一个真子集。

wps_clip_image-29890

可重入函数通常比不可重入函数高效一些,因为不需要同步操作。

如果所有的函数参数都是传值传递(没有指针),且所有的数据引用都是本地的自动栈变量(没有引用静态或全局变量),则函数是显式可重入的,无论如何调用,都没有问题。

允许显式可重入函数中部分参数用指针传递,则隐式可重入的。在调用线程时小心传递指向非共享数据的指针,它才是可重入。如rand_r

可重入性同时是调用者和被调用者的属性。

3)竞争

当一个程序的正确性依赖于一个线程要在另一个线程到达y点之前到达它的控制流中的x点时,就会发生竞争。

4)死锁

信号量引入了一种潜在的令人厌恶的运行时错误,叫做死锁,它指的是一组线程被阻塞了,等待一个永远也不会为真的条件。

避免死锁是很困难的。当使用二进制信号量来实现互斥时,可以用如下规则避免:

如果用于程序中每对互斥锁(s,t),每个既包含s也包含t的线程都按照相同顺序同时对它们加锁,则程序是无死锁的。

网友评论

登录后评论
0/500
评论
指尖的舞曲
+ 关注