并发编程 - CS:APP 第十二章
本章主要内容:
- 实现并发程序的三种方法:
- fork() 进程
- I/O 多路复用
- 使用线程
- 使用信号量同步线程
- 线程安全问题
使用进程进行并发编程
fork()
& execve
优点:进程模型清晰,有独立的地址空间
缺点:不方便进程之间共享信息
基于进程的并发echo服务器
1 |
|
IO多路复用
可以使用select()
函数显示等待一个进程有一个IO事件发生。
例如我们有一个监听描述符和很多链接描述符,如果有一个描述符准备好读,我们就相应它,这样也可以实现并发。
只要有一个IO事件发生,程序的逻辑流就会改变。
1 |
|
1 |
|
缺点:编码特别复杂
基于线程的并发编程
线程是运行在进程上下文中的逻辑流
运行在同一个进程里的线程共享
- 虚拟地址空间
有自己独立的
- 栈、栈指针、PC、通用目的寄存器和条件码
线程之间是对等的,没有和进程一样有父子之分
进程有两种状态:可结合的和分离的
- 可结合的:能被其他线程回收和杀死,但它的内存资源需要被显示回收
- 分离的:不能被其他线程杀死,内存资源结束时由系统自动释放
1 |
|
用信号量同步线程
如果两个线程交错地调用某个共享变量,如果第一个线程还没有将新值更新,第二个线程就已经取出了共享变量的值,就可能会造成错误
对于线程i,操作共享变量的指令,构成了一个关于共享变量的临界区,这个临界区不应该和其他程序的临界区交错执行。换句话说,我们想要确保每个线程在执行它的临界区中的指令时,拥有对共享变量互斥的访问。
想要实现互斥的访问,我们可以使用信号量机制
信号量:
信号量s是具有非负整数值的全局变量,只能通过两种操作来改变它:
- P(s) : 如果s是非零的,那么P将s减1,并且立即返回。如果s为零,那么就挂起进程,直到s变为非零,并且该进程被一个V操作重启。在重启之后,P操作将s减1,并将控制返回给调用者。
- V(S) : V操作将s加1。如果有任何进程阻塞在P操作等待s变成非零,那么V操作会重启这些进程中的一个,然后该进程将s减1,完成它的P操作。
P中测试和加一的操作是不可分割的
V中测试和加以的操作也是不可分割的
如果s的值只能是0或者1,我们就将这个信号量成为互斥锁,它可以提供对共享变量互斥的访问。
互斥锁的使用:当一个线程要使用共享变量时,它对S进行P操作,互斥锁加锁,S变为0,当另一个线程想要使用共享变量时,他也对s进行P操作,因为P已经变为了0,所以这个线程被挂起,等待一个其他线程的V操作将它激活。当第一个线程使用完共享变量,它执行V操作,互斥锁解锁,第二个线程被激活。
生产者消费者问题
生产者线程反复地生成新的项目(item),并把它们插入到缓冲区中。消费者线程不断地从缓冲区中取出这些项目,然后消费它们。
因为插入和取出项目都包括更新共享变量,所以我们必须保证对缓冲区的访问是互斥的。但是只保证互斥访问是不够的,我们还需要调度对缓冲区的访问。如果缓冲区是满的(没有空的槽位),那么生产者必须等待直到有一个槽位变为可用。与之相似,如果缓冲区是空的(没有可取用的项目),那么消费者必须等待直到有一个可用的项目。
基于生产者消费者的sbuf包:
sbuf.h
1 |
|
sbuf.c
1 |
|
基于预线程化的并发服务器
1 |
|
线程安全
四个种不安全的函数
- 不保护共享变量的函数
- 保持跨越多个调用的状态的函数(依赖前次调用结果的函数)
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
可重入的函数
通常与线程安全的函数相混淆,但其实可重入的函数是线程安全函数的子集
定义:当它被多个线程使用时,不会引用任何共享数据
竞争
一个程序的正确性依赖于一个线程要在另一个线程到达y点之前到达它控制流种的x点时,就会发生竞争
死锁
deadlock:一个程序被阻塞了,等待一个永远也不会为真的条件
给定所有互斥操作的一个全序,如果每个线程都是以一种顺序获得互斥锁,并以相反的顺序释放,那么这个程序是无死锁的