深入理解计算机系统第12章 并发编程

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/u010772289/article/details/86532570

第12章 并发编程

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

  • 进程: 每个逻辑控制流都是一个进程,由内核来调度和维护.
  • I/O多路复用: 逻辑流被模型化为状态机。程序是一个单独的进程,所有的流都共享同一个地址空间.
  • 线程

12.1 基于进程的并发编程

构造并发服务器的一个方法是,在父进程中接受每个客户端的请求,然后创建一个子进程为每个新客户端服务。

现假设有两个客户端和一个服务器,服务器正在监听一个监听描述符(如描述符3),服务器接受了客户端1的连接请求,并返回一个已连接描述符(如描述符4)。在接受连接请求后,服务器派生一个子进程,这个子进程获得服务器描述符表的完整拷贝。子进程关闭拷贝的监听描述符3,父进程关闭它的已连接描述符4的拷贝,因为不再需要这些描述符。父子进程中的已连接描述符指向同一个文件表表项,所以父进程若不关闭它的已连接描述符,将永远不会释放已连接描述符4的文件表条目,由此引起存储器泄露,最终导致消耗尽可用的存储器,使系统崩溃.

1.基于进程的并发服务器

图12-5展示了基于进程的并发echo服务器代码。对于这个服务器:

  • 通常服务器会运行很长时间,所以必须包含一个SIGCHLD处理程序,来回收僵死子进程的资源(第4–9行)。因SIGCHLD处理程序执行时,SIGCHLD信号是阻塞的,且Unix信号是不排队的,所以SIGCHLD程序需要准备好回收多个僵死子进程的资源。这里waitpid的options选项用了WNOHANG,表示如果等待集合中的任何子进程都还没有终止,那么就立刻返回,返回值为0.
  • 父子进程必须关闭它们各自的connfd,尤其对于父进程,它必须关闭它的已连接描述符,以避免存储器泄露。
  • 因为文件表表项中的引用计数,直到父子进程的connfd都关闭了,到客户端的连接才会终止。
    图12-5基于进程的并发echo服务器

2.进程的优劣势

进程共享文件表,但不共享用户地址空间。进程有独立的地址空间既是优点也是缺点.

  • 优: 一个进程不可能不小心覆盖另一个进程的虚拟存储器,这就消除了很多令人迷惑的错误.
  • 劣: 独立的地址空间使得进程共享状态信息变得更加困难,为了共享信息,它们必须使用显式的IPC(进程间通信)机制。另一个缺点是,它们往往较慢,因为进程控制和IPC的开销很高。

12.2 基于I/O多路复用的并发编程(待完善)

12.3 基于线程的并发编程

线程:就是运行在进程上下文中的逻辑流。

每个线程都有它自己的线程上下文,包括一个唯一的整数线程ID(Thread ID, TID)、栈、栈指针、程序计数器、通用目的寄存器和条件码。所有运行在一个进程中的线程共享该进程的整个虚拟地址空间。包括它的代码、数据、堆、共享库和打开的文件。

1.线程执行模型

  • 主线程(main thread): 每个进程开始生命周期都是单一线程,这个线程称为主线程。
  • 对等线程(peer thread): 在某一时刻,主线程创建一个对等线程。此时开始,两个线程并发的运行。
    在这里插入图片描述
    进程与线程执行的不同:
  • 因一个线程的上下文比一个进程的上下文小得多,线程的上下文切换要比进程的上下文切换快得多
  • 不同于进程,线程不是按照严格的父子层次来组织的。和一个进程相关的线程组成一个对等(线程)池(pool),独立于其他线程创建的线程。对等(线程)池的主要影响是,一个线程可以杀死它的任何对等线程,或者等待它的任意对等线程终止。另外,每个对等线程都能读写相同的共享数据。

2.Posix线程

在这里插入图片描述
由图12-13可知,每个线程例程都以一个通用指针作为输入,并返回一个通用指针。如果想传递多个参数给线程例程,应将参数放在一个结构中,并传递一个指向该结构的指针。相似的,如果想要线程例程返回多个参数,可以返回一个指向一个结构的指针。

3.创建线程

#include <pthread.h>

typedef void *(func)(void *);

int pthread_create(pthread_t *tid, pthread_attr_t *attr, func *f, void *arg);
                        返回: 若成功返回0,若出错则为非0
pthread_t pthread_self(void);     返回: 返回调用者的线程ID

pthread_create: 创建一个新线程,arg是输入变量,在新线程上下文中运行线程例程f。atrr参数用于改变新线程的默认属性。tid用于返回新线程的ID
pthread_self: 获取线程自己的ID

4.终止线程

线程以下列方式来终止:

  • 线程例程返回时, 线程会隐式地终止
  • 通过调用pthread_exit函数,线程会显式地终止。若是主线程调用了pthread_exit,它会等待其他所有对等线程终止,然后再终止主线程和整个进程
  • 某个对等线程调用Unix的exit函数,该函数终止进程及该进程所有相关线程。
  • 另一个对等线程调用pthread_cancle(参数为当前线程ID),来终止当前线程。
#include <pthread.h>

int pthread_cancel(pthread_t tid);
                  返回: 若成功返回0,出错则为非0

5.回收已终止线程的资源

线程通过调用pthread_join函数等待其他线程终止

#include <pthread.h>

int pthread_join(pthread_t tid, void **thread_return);
                  返回: 若成功返回0,出错则为非0

pthread_join函数会阻塞,直到线程tid终止,将线程例程返回的(void*)指针赋值为pthread_return指向的位置,然后回收已终止线程占用的所有存储器资源.

和Unix的wait函数不同,pthread_join函数只能等待一个指定的线程终止,没有办法让pthread_join等待任意一个线程终止。

6.分离线程

线程是可结合的(joinable)可分离的(detached)

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

默认情况,线程被创建成可结合的。为避免存储器泄露,每个可结合线程都应该:

  • 要么被其他线程显式回收
  • 要么通过调用pthread_detach函数被分离。
#include <pthread.h>

int pthread_detach(pthread_t tid);   返回: 若成功则返回0,出错则为非0

7.初始化线程

#include <pthread.h>

pthread_once_t once_control = PTHREAD_ONCE_INIT;

int pthread_once(pthread_once_t *once_control,
                  void (*init_routine)(void));

当需要动态初始化多个线程共享的全局变量时,pthread_once函数很有用

8.一个基于线程的并发服务器

在这里插入图片描述
在这里插入图片描述

这里,如果while循环里,写成

connfd = Accept(...);
Pthread_create(&tid, NULL, thread, &connfd);

这样会在线程例程的赋值语句和主线程的accept语句间引入竞争。若赋值语句是在下一个accept之前完成的,那么对等线程例程中的局部变量connfd就得到了正确的描述符值。然而,若实在accept之后,connfd会得到下一次连接的描述符值。为避免竞争,这里将每个accept返回的已连接描述符分配到它自己的动态分配的存储器块。

12.4 多线程程序中的共享变量

1.线程存储器模型

一组并发线程运行在一个进程的上下文中。每个线程都有它独立的线程上下文,包括线程ID、、栈指针、程序计数器、条件码和通用目的寄存器值

  • 每个线程和其他线程一起共享进程上下文的剩余部分。这包括整个用户虚拟地址空间,它由文本(代码)、读/写数据、堆以及所有的共享库代码和数据区域组成。线程
  • 线程也共享打开文件的集合

各自独立的线程栈的存储器模型不是很整齐清楚。这些栈被保存在虚拟地址空间的栈区域中,且通常是被相应的线程独立的访问。但不同的线程栈是不对其他线程设防的。所以,若一个线程得到了一个指向其他线程栈的指针,那它就可以读写这个栈。

2.将变量映射到存储器

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

  • 全局变量: 定义在函数之外的变量。运行时,虚拟存储器的读/写区域只包含每个全局变量的一个实例,任何线程都可以引用。也就是全局变量保存在虚拟存储器数据区域。
  • 本地自动变量: 定义在函数内部且没有static属性的变量。运行时,每个线程的栈都包含它自己的所有本地自动变量的实例,也就是本地自动变量保存在每个线程的栈中。
  • 本地静态变量: 定义在函数内部且有static属性的变量。和全局变量一样,虚拟存储器的读/写区域只包含每个本地静态变量的一个实例.

共享变量: 一个变量是共享的,当且仅当它的一个实例被一个以上的线程引用.

12.5 用信号量同步线程

共享变量十分方便,但也引入了同步错误的可能性.

考虑图12-16中程序,它创建了两个线程,每个线程都对共享计数变量cnt加1.每个线程都对计数器增加niters次,预计其最终值是2*niters。然而结果却不是这样。
在这里插入图片描述
在这里插入图片描述

研究计数器循环(第39~40行)的汇编代码,如图12-17.
在这里插入图片描述
结果与执行的指令顺序有关,如图12-18.
在这里插入图片描述

1.进度图

进度图: 将n个并发线程的执行模型化为一条n维笛卡尔空间中的轨迹线.

图12-20展示了图12-16程序第一次循环迭代的二维进度图。水平轴对应于线程1,垂直轴对应于线程2。点(L1,S2)对应于线程1完成了L1而线程2完成了S2的状态。
图12-20展示了下面指令顺序对应的轨迹线: H1, L1, U1, H2, L2, S1, T1, U2, S2, T2.

对于线程i,操作共享变量cnt内容的指令(Li,Ui,Si)构成了一个(关于共享变量cnt的)临界区,这个临界区不应该和其他进程的临界区交替执行。换句话,我们想要确保每个线程在执行它的临界区中的指令时,拥有堆共享变量的互斥的访问。通常这种现象称为互斥
在这里插入图片描述

2.信号量

信号量是一种经典的解决同步不同执行线程问题的方法。信号量s是具有非负整数值的全局变量,只能由两种操作来处理,即P和V.

  • P(s): 测试s是否为0,若不为0,则将s减1,否则就挂起这个线程,直到s变为非0.
  • V(s): 将s加1。若有任何线程阻塞在P操作等待s变为非0,那么V操作会重启这些线程中的一个,然后该线程s将减1,完成它的P操作。
    P中的测试和减1操作是不可分割的(称为原子操作),也就是一旦预测信号量s变为非零,就会将s减1,不能有中断。V中的加1操作也是不可分割的。

Posix标准定义了操作信号量的函数:

#include <semaphore.h>

int sem_init(sem_t *sem, 0, unsigned int value);
int sem_wait(sem_t *s);   // P(s)
int sem_post(sem_t *s);   // V(s)
                        返回: 若成功则为0,出错则为-1

sem_init函数将信号量sem初始化为value。通过调用sem_wait,sem_post函数来执行P和V操作,记作:

void P(sem_t *s);  // sem_wait的包装函数
void V(sem_t *s);  // sem_post的包装函数

3.使用信号量来实现互斥

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

这种信号量叫做二元信号量,因为它的值总是0或1. 提供互斥为目的的二元信号量常常也称为互斥锁(mutex),在互斥锁上执行P操作称为加锁,执行V操作称为解锁

4. 利用信号量来调度共享资源

信号量的作用:

  • 提供互斥
  • 调度对共享资源的访问。

两个经典例子:

  • 生产者-消费者问题
  • 读者-写真问题
4-1.生产者-消费者问题

生产者和消费者线程共享一个有n个槽的有限缓冲区。生产者线程反复生成新的项目,并把它们插入到缓冲区。消费者线程不断从缓冲区取出这些项目,然后消费它们。

因为插入和取出项目都涉及更新共享变量,所以必须保证对缓冲区的访问是互斥的。另外,还需要调度对缓冲区的访问。若缓冲区是满的,那么生产者必须等待直到有一个槽位变为可用。若缓冲区是空的,消费者必须等待直到有一个项目变为可用。

开发一个简单的包,叫做SBUF,用以构造生产者-消费者程序。
三个信号量同步对缓冲区的访问。mutex信号量提供互斥的缓冲区访问。slots和items信号量分别记录空槽位和可用项目的数量.
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

4-2.读者-写者问题

一组并发的线程要访问一个共享对象,例如一个主存中的数据结构,或者一个磁盘上的数据库。有些线程只读对象(读者),而其他的线程只修改对象(写者)。写者必须拥有对对象的独占的访问,而读者可以和无限多个其他的读者共享对象。

读者-写者问题的变种:

  • 第一类读者-写者问题:读者优先,要求不要让读者等待,除非已经把使用对象的权限赋予了写者
  • 第二类读者-写者问题:写者优先,要求一旦一个写者准备好写,它就会尽可能快的完成写操作

第一类读者-写者问题解答如图12-26:

  • 信号量w控制对访问共享对象的临界区的访问。

  • 信号量mutex保护堆共享变量readcnt的访问,readcnt统计当前在临界区中的读者数量。

  • 写者: 每当一个写者进入临界区时,它对互斥锁w加锁,离开时解锁。者保证了任意时刻临界区中只有一个写者

  • 读者: 只有第一个进入临界区的读者对w加锁,而只有最后一个离开临界区的读者对w解锁。若读者并非第一个进入或最后一个离开临界区,则忽略互斥锁w。这样,只要还有一个读者占用互斥锁w,其他读者可以没有障碍的进入临界区

但上述读者-写者问题的解答容易引起饥饿,饥饿就是一个线程无限期的阻塞,无法进展。如图12-26,若有读者不断到达,写者可能无限期的等待。
在这里插入图片描述

5.基于预线程化的并发服务器

在图12-14所示并发服务器中,我们为每个新客户端都会创建一个新线程,这会导致不小的代价。一个基于预线程化的服务器试图通过生产者-消费者模型来降低这种开销。服务器由一个主线程和一组工作者线程构成。主线程不断接受来自客户端的连接请求,并将得到的连接描述符放在一个有限缓冲区中。每一个工作者线程反复从共享缓冲区中取出描述符,为客户端服务,然后等待下一个描述符。

图12-28是用SBUF包实现的一个预线程化的并发echo服务器。初始化缓冲区sbuf后,主线程创建了一组工作者线程。然后不断循环,接受连接请求,并将得到的已连接描述符插入到缓冲区sbuf中国。每个工作者线程则不断等待直到它能从缓冲区中取出一个已连接描述符,然后调用echo_cnt函数回送客户端的输入。
在这里插入图片描述
在这里插入图片描述

12.6 使用线程提高并行性

目前为止,我们都是假设并发线程是在单处理器系统上执行的。然而,许多现代机器具有多核处理器。并发程序通常在这样的机器上运行更快,因为操作系统内核在多个核上并行的调度这些并发线程,而不是在单个核上顺序的调度。

并行程序: 是一个运行在多个处理器上的并发程序。因此,并行程序的集合是并发程序的真子集。考虑如何并行的对一列整数0,1,…,n-1求和。
最直接的方法是将序列划分为t个不相交的区域,然后给t个不同的线程每个分配一个区域。假设n是t的倍数,这样每个区域有n/t个元素。
在这里插入图片描述
在这里插入图片描述
图12-32是线程例程程序。我们给每个对等线程一个唯一的存储位置来更新,这样就不再需要使用信号量互斥锁来同步对psum数组的访问。这时,唯一需要痛的是主线程必须等待每个对等线程结束。
在这里插入图片描述
探讨12-31程序的运行时间会发现。随着线程数增加,运行时间下降,直到增加到四个线程,此时,运行时间趋于平稳,设置开始增加了一点点。这是由于在一个核上运行多个线程上下文切换的开销。由于这个原因,并行程序常写为每个核上只运行一个线程。

12.7 其他并发问题

1.线程安全

  • 线程安全
  • 线程不安全

四类线程不安全函数:

  • 不保护共享变量的函数: 利用像P和V这样的同步操作来保护共享变量,从而使得线程不安全函数变成线程安全的
  • 保持跨越多个调用的状态的函数: 如rand函数是线程不安全的,因为当前调用的结果依赖于前次调用的中间结果。唯一解决方法就是重写它,使它不再使用任何static数据,而是依靠调用者在参数中传递状态信息。
  • 返回指向静态变量的指针的函数: 两种解决方法: 1.重写函数,使得调用者传递存放结果的变量的地址,这就消除了所有共享数据; 2.加锁-拷贝技术: 利用互斥锁来调用线程不安全函数,得到函数结果,并将结果拷贝到一个私有的存储器位置,然后对互斥锁解锁。如图12-36给出了ctime的线程安全版本
  • 调用线程不安全函数的函数
    在这里插入图片描述

2.可重入性

可重入函数:具有这样一种属性:当它们被多个线程调用时,不会引用任何共享数据。可重入函数是线程安全的.

3.在线程化的程序中使用已存在的库函数

大多数Unix函数,包括定义在标准C库中的函数(例如malloc、free、realloc、printf和scanf)都是线程安全的,只有一小部分是例外.
在这里插入图片描述
Unix系统提供大多数线程不安全函数的可重入版本,可重入版本名称总是以"_r"后缀结尾.

4.竞争

5.死锁

信号量可能会引入死锁,它是指一组线程被阻塞了,等待一个永远也不会为真的条件.

猜你喜欢

转载自blog.csdn.net/u010772289/article/details/86532570
今日推荐