CSAPP Note chap12 Part A
CSAPP 读书笔记系列chap12
chap 12 并发编程
这一次说的是多线程并发编程,是也一个比较广泛的话题.
对于并发编程为什么会引起的一些问题,可以看下SICP,之前的简单博客笔记
这一章要讲的内容很多,打算分开两部分Part A和part B 来写
PartA讲服务器的三种并发编程
- 基于进程
- 基于事件
- 基于线程
PartB讲并发编程的一些基本概念
- 共享变量
- 信号量,进度图和互斥
- 生产者-消费者问题 和读者写者问题
- 线程安全,可重入函数
- 竞争和死锁
PartA
这是第一篇,为Part A
迭代服务器
chap11 说了一个迭代echo服务器,server每一次只能服务于一个客户端。一次只能处理一个请求,只有当前的请求处理完了,才能继续处理下一个。
实用性很小,适用于时间服务器,DHCP服务器等
其处理请求的流程图如下:
只有当 Client 1 断开之后,Server 才会处理 Client 2 的请求。具体是在哪里等待呢?因为 TCP 会缓存,所以实际上 Client 2 在 ret read 之前进行等待。
而使用并行策略,可以同时处理不同客户端发来的请求。
基于进程的并发编程服务器
服务端为每个客户端分离出一个单独的进程,是建立了连接之后才开始并行,连接的建立还是串行的。 如下图
步骤
步骤为:
- 服务器监听listen fd 3 ,接受客户端的连接请求,返回connfd 4;
- 服务器派生一个子进程为这个客户端connfd 4服务
- 服务器监听listen fd 3 ,接受另一个连接请求,返回connfd 5;
- 服务器派生一个另一个子进程为新的客户端connfd 5服务
这里子进程和父进程共享一个文件表(chap10),涉及到文件的引用计数和内存泄露,所以父子进程都必须关闭各自的connfd和listenfd
- 内核会保存每个 socket 的引用计数,在 fork 之后 refcnt(connfd) = 2,所以在父进程需要关闭 connfd,这样在子进程结束后引用计数才会为零
同时,因为子进程结束得回收,所以得注册SIGCHLD回收函数
具体可以看代码
代码如下:
1 | void sigchld_handler(int sig){ |
### 基于进程的优劣
基于进程的方式可以并行处理连接,除了共享已打开的 file table 外,无论是 descriptor 还是全局变量都不共享,不大容易造成同步问题,比较简单粗暴。
但是带来了额外的进程管理开销,并且进程间通讯不便,需要使用 IPC (interprocess communication)。
进程间的通信几种方式可以看之前的chap8 进程-信号
另外,进程的花销很大,如果每次都是fork一个子进程,不现实
基于I/O多路复用的并发事件驱动服务器
I/O复用函数
IO多路复用是指内核挂起进程,当发现进程指定的一个或者多个IO条件准备读取,才将控制返回给应用程序。
I/O一般指的是,调用的select,poll或epoll函数,对于三者的区别,可以参考下这一篇博客.https://www.jianshu.com/p/dfd940e7fca2
书上说的是select,适用于连接数不大,但连接访问频繁的情况.之前堡垒机项目也是用这个.
select函数简述
函数原型如下:1
2
3
4
5
6
7
8
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
void FD_ZERO(fd_set *set); // 置零fd_set中所有的位
void FD_CLR(int fd, fd_set *set); // 将fd_set对于的fd置零
void FD_SET(int fd, fd_set *set); // 将fd_set对于的fd置一
int FD_ISSET(int fd, fd_set *set); // fd是否在fd_set中
fd_set 为 描述符集合,是一个大小为nfds的位向量;使用上面四个FD_宏参数修改或查询
也就是服务器会维护一个 connection 数组,包含若干 connfd,每个输入请求都被当做事件,然后每次从已有的事件中选取一个进行处理。
更详细的select函数介绍可以看书chap12.2
select echo服务器
select服务器流程如下:
其简单的代码如下:
1 | int main(int argc, char **argv) { |
上面的select是比较简单的,实际上可以实现更小粒度的多路复用,使用状态机,线程池等功能.这里可以留给lab来实现
基于I/O多路复用的并发事件驱动
I/O多路复用是并发事件驱动的基础,事件驱动程序中,一些事件的发生会推使文件流向前发展.可以简单地把逻辑流模型看为一个状态机, 输入事件的状态变化会转移到另一种状态.
许多服务器是基于事件驱动的,例如nginx,apache,muduo等
基于事件驱动的好处在于只使用一个逻辑控制流和地址空间,程序员可以对程序行为有更多的控制.
同时可以利用调试器进行单步调试(其他的方法因为并行的缘故基本没办法调试),也不会有进程/线程控制的开销。
但是相比之下,代码的逻辑复杂度会比较高,很难进行精细度比较高的并行,也无法发挥多核处理器的全部性能。
基于线程的并发编程服务器
基于线程和基于进程的方法非常相似,这里用主线程等待请求,然后创建一个对等线程去处理请求。
要说线程的并发编程服务器,就先说线程吧.
线程
之前一直说什么线程是操作系统能够进行运算调度的最小单位,那是废话…
线程基本概念
线程具体就是运行在进程上下文的逻辑流,而进程是操作系统对一个执行中程序的实例的一个抽象
进程其实花销很大的,一个进程包括进程上下文、代码、数据和栈,有自己独立的地址空间(见chap8)。如果从线程的角度来描述,一个进程则包括线程、代码、数据和上下文。也就是说,线程作为单独可执行的部分,被抽离出来了,一个进程可以有多个线程。
每个线程有自己的线程上下文(thread context,包括自己的唯一线程 id,栈,程序计数器,通用目的寄存器,但是没有单独的 heap).自己的用来保存局部变量的栈(其他线程可以修改),会共享所有的代码、数据以及内核上下文。
和进程不同的是,线程没有一个明确的树状结构(使用 fork 是有明确父进程子进程区分的)。和进程中『并行』的概念一样,如果两个线程的控制流在时间上有『重叠』(或者说有交叉),那么就是并行的。
注意:
同时,线程和同一个进程相关的线程组成同一个对等(线程)池,独立于其他线程(???原书为线程,但个人认为是进程)创建的线程.线程池中,一个线程可以杀死任何其他的对等线程
进程和线程的差别已经被说了太多次,这里简单提一下。相同点在于,它们都有自己的逻辑控制流,可以并行,都需要进行上下文切换。不同点在于,线程共享代码和数据(进程通常不会),线程开销比较小(创建和回收)
POSIX线程 POSIX Threads
Pthreads 是一个线程库,基本上只要是 C 程序能跑的平台,都会支持这个标准。Pthreads定义了一套C语言的类型、函数与常量,它以 pthread.h 头文件和一个线程库实现。
Pthreads API 中大致共有 100 个函数调用,全都以 pthread_ 开头,并可以分为四类:
- 线程管理,例如创建线程,等待(join)线程,查询线程状态等。
- Mutex:创建、摧毁、锁定、解锁、设置属性等操作
- 条件变量(Condition Variable):创建、摧毁、等待、通知、设置与查询属性等操作
- 使用了读写锁的线程间的同步管理
下面给几个线程相关的函数
可以man来看
创建线程pthread_create
1 | #include <pthread.h> |
- thread 为线程 id
- attr为新线程的属性,一般为NULL
- start_routine为 欲在新线程上运行的routine
- arg 表示接受一个新的输入变量
分离线程
线程的状态为两种,
- 可结合(joinable),能够被其他线程回收或杀死
- 分离(detached),不能够被其他线程回收或杀死,由系统自动释放资源
1 | int pthread_detach(pthread_t thread); |
终止线程
终止线程有几种方式
- routine 运行完,隐式终止
- 某个对等线程调用exit函数,终止进程及其相关的线程
- 调用pthread_exit 或 pthread_cancle
回收已终止线程的资源
调用pthread_join,回收thread TID的线程1
int pthread_join(pthread_t thread, void **retval);
初始化线程
调用pthread_once
基于线程的echo并发编程服务器
1 | // Thread routine |
在这个模型中,每个客户端由单独的线程进行处理,这些线程除了线程 id 之外,共享所有的进程状态(但是每个线程有自己的局部变量栈)。
使用线程并行,能够在不同的线程见方便地共享数据,效率也比进程高,但是共享变量可能会造成比较难发现的程序问题,很难调试和测试。
总结
一般来说,服务器的实现方式为以上几种.各有其优劣,根据实际情况来决定,现实中多采用基于事件,使用多线程池