并发编程
本章的核心内容:
- 实现并发的三种方式(进程、IO复用、线程),以及他们的优缺点
- 如何使用信号量来处理资源互斥、共享问题
- 认识不安全和死锁的概念
并发在哪些情况下有用?
- 访问慢速I/O设备。
- 与人交互。
- 通过推迟工作以降低延迟。
- 服务多个网络客户端。
- 在多核机器上进行并行计算。
实现并发有哪些方式?
- 进程。用这种方法,每个逻辑控制流都是一个进程,由内核来调度和维护。因为进程有独立的虚拟地址空间,想要和其他流通信,控制流必须使用某种显式的进程间通信机制。
- I/O多路复用。在这种形式的并发编程中,应用程序在一个进程的上下文中显式地调度它们自己的逻辑流。逻辑流被模型化为状态机,数据到达文件描述符后,主程序显式地从一个状态转换到另一个状态。因为程序是一个单进程,所以所有的流都共享同一地址空间。
- 线程。线程是运行在一个单一进程上下文中的逻辑流,由内核进行调度。你可以把线程看成是其他两种方式的混合体,像进程流一样由内核进行调度,而像I/O多路复用流一样共享同一虚拟地址空间。
基于进程的并发编程
如何工作?
构造并发编程最简单的方法就是用进程,使用那些大家都很熟悉的函数,像fork、exec和waitpid。
步骤:
- 服务器监听一个监听描述符上的连接请求。
- 服务器接受了客户端1的连接请求,并返回一个已连接描述符。
- 在接受了连接请求之后,服务器派生一个子进程,这个子进程获得服务器描述符表的完整拷贝。子进程关闭它的拷贝中的监听描述符3,而父进程关闭它的已连接描述符4的拷贝,因为不再需要这些描述符了。
- 子进程正忙于为客户端提供服务,父进程继续监听新的请求。
注意点:
- 首先,通常服务器会运行很长的时间,所以我们必须要包括一个SIGCHLD处理程序,来回收僵死子进程的资源。因为当SIGCHLD处理程序执行时,SIGCHLD信号时阻塞的,而Unix信号时不排队的,所以SIGCHLD处理程序必须准备好回收多个僵死子进程的资源。
- 其次,子进程必须关闭它们各自的connfd拷贝。就像我们已经提到过的,这对父进程而言尤为重要,它必须关闭它的已连接描述符,以避免存储器泄漏。
- 最后,因为套接字的文件表表项中的引用计数,直到父子进程的connfd都关闭了,到客户端的连接才会终止。
优缺点?
共享文件表,但是不共享用户地址空间。
- 优点:不会出现“一个进程不可能不小心覆盖另一个进程的虚拟存储器”的问题
- 缺点:独立的地址空间使得进程共享状态信息变得更加困难。为了共享信息,它们必须使用显式的IPC(进程间通信)机制。基于进程的设计的另一个缺点是,它们往往比较慢,因为进程控制和IPC的开销很高。
案例:
基于I/O多路复用的并发编程
什么是I/O多路复用?
Unix网络编程给IO分了5种IO模型,其中包含阻塞IO、非阻塞IO、IO多路复用:
- 阻塞IO:进程发起IO系统调用后,进程被阻塞,转到内核空间处理,整个IO处理完毕后返回进程。操作成功则进程获取到数据。
- 非阻塞IO:进程发起IO系统调用后,如果内核缓冲区没有数据,需要到IO设备中读取,进程返回一个错误而不会被阻塞;进程发起IO系统调用后,如果内核缓冲区有数据,内核就会把数据返回进程。
- IO多路复用: 多个的进程的IO可以注册到一个复用器(select)上,然后用一个进程调用该select, select会监听所有注册进来的IO;
更多详细参见:IO模型
什么是事件驱动程序?
事件推动逻辑的执行,一般可以模型化状态机。
工作方式?
使用select
优点:
- 使用事件驱动编程,这样比基于进程的设计给了程序更多的对程序行为的控制。
- 一个基于I/O多路复用的事件驱动服务器是运行在单一进程上下文中的,因此每个逻辑流都访问该进程的全部地址空间。这使得在流之间共享数据变得很容易
缺点:
- 事件驱动设计的一个明星的缺点就是编码复杂。我们的事件驱动的并发服务器需要比基于进程的多三倍。
- 不能充分利用多核处理器。
基于线程的并发编程
使用线程的好处?
- 因为一个线程的上下文要比一个进程的上下文小很多,线程的上下文切换要比进程的上下文切换快得多。
- 每个对等线程都能读写相同的共享数据
api:
- pthread_create():创建一个线程
- pthread_exit():终止当前线程
- pthread_cancel():请求中断另外一个线程的运行。
- pthread_join():阻塞当前的线程,直到另外一个线程运行结束
- pthread_detach():分离线程,分离后不需要被其他线程回收,终止时自行回收
案例:
多线程程序中的共享变量
线程的基础内存内存模型是什么?
一组并发线程运行在一个进程的上下文中。 每个线程都有它自己独自的线程上下文,包括线程ID、栈、栈指针、程序计数器、条件码和通用目的寄存器值。每个线程和其他线程一起共享进程上下文的剩余部分。这包括整个用户虚拟地址空间,它是由只读文本(代码)、读/写数据、堆以及所有的共享库代码和数据区域组成的。线程也共享同样的打开文件的集合。
各自独立的线程栈的存储器模型不是那么整齐清楚的。这些栈被保存在虚拟地址空间的栈区域中,并且通常是被相应的线程独立地访问的。我们说通常而不是总是,是因为不同的线程栈是不对其他线程设防的。所以,如果一个线程以某种方式得到一个指向其他线程栈的指针,那么它就可以读写这个栈的任何部分。
根据这个模型,变量实例是如何映射到内存的?
- 全局变量:定义在函数之外的变量。在运行时,虚拟存储器的读/写区域只包含每个全局变量的一个实例,任何线程都可以引用。
- 本地自动变量:定义在函数内部但是没有static属性的变量。在运行时,每个线程的栈都包含它自己的所有本地自动变量的实例。即使当多个线程执行同一个线程例程时也是如此。
- 本地静态变量:定义在函数内部并有static属性的变量。和全局变量一样,虚拟存储器的读/写区域只包含在程序中声明的每个本地静态变量的一个实例。
有多少线程引用这些实例?
一个变量是共享的,当且仅当它的一个实例被一个以上的线程引用。
用信号量同步线程
共享变量带来的问题?
共享变量引入了同步错误。
譬如下面一段代码:i++(注意这不是一个原子操作)
如何分析同步错误的问题?
可以使用线程进度图来分析,如下:
对每个线程而言,操作共享变量的指令区间叫做临界区
两个临界区的交集称作不安全区
什么是信号量?
信号量是一个非负整数,关键在于对信号量的操作(增减)是原子的
如何使用信号量解决同步问题?
只用0和1两种值的信号量叫二元信号量,也叫互斥锁
用互斥锁把临界区代码包起来
上面的代码使得线程的轨迹无法进入不安全区
如何使用信号量来处理共享资源的访问问题?
信号量>1
生产者——消费者问题?
有限缓存抽象如下:
生产和消费方式如下:
读者——写者问题?
修改对象的线程叫做写者;只读对象的线程叫做读者。
写着必须拥有对对象的独占访问,而读者可以和无限多个其他读者共享对象。
分两类:
- 读者优先,要求不要让读者等待,除非已经把使用对象的权限赋予了一个写者。
- 写者优先,要求一定能写者准备好可以写,它就会尽可能地完成它的写操作。
第一类解决方案如下:
什么是饥饿?
线程无限制阻塞,无法进展。比如上面的写者就有可能永远拿不到锁
综合:基于预线程化的并发服务器
什么是预线程化?
案例:
使用线程提高并行性
顺序、并发、并行之间是什么关系?
多线程在多核下的性能一定更好吗?
多核下同步操作的性能消耗更大,因此要尽可能避免
线程越多性能越好吗?
时间上升,是因为没有阻塞的环境下,一个核的多线程上下文切换导致
所以,并行程序常常是每个核一个线程
其他并发问题
什么是线程不安全的?
一个函数被称为线程安全的,当且仅当被多个并发线程反复地调用时,它会一直产生正确的结果。如果一个函数不是线程安全的,我们就说它是线程不安全的。
线程不安全的函数类有哪些?**
- 不保护共享变量的函数。
- 保持跨越多个调用的状态的函数。如伪随机数生成器
- 返回指向静态变量的指针的函数。
- 调用线程不安全函数的函数。
解决方案:除了第二种必须修改源码外:其余的都可以采用“加锁-拷贝技术”:线程不安全函数与互斥锁联系起来,在每一个调用位置,对互斥锁加锁,调用线程不安全函数,将函数返回的结果拷贝到一个私有的存储器位置,然后对互斥锁解锁。
什么是可重入函数?和线程安全函数有啥区别?
当它们被多个线程调用时,不会引用共享数据。
如何通过线程进程图来判断死锁?
避免死锁的加锁规则?
每个线程都是以相同的顺序获得互斥锁
上面结论有证实