chap12PartA

CSAPP Note chap12 Part A

CSAPP 读书笔记系列chap12

chap 12 并发编程

这一次说的是多线程并发编程,是也一个比较广泛的话题.

对于并发编程为什么会引起的一些问题,可以看下SICP,之前的简单博客笔记

这一章要讲的内容很多,打算分开两部分Part A和part B 来写

  • PartA讲服务器的三种并发编程

    • 基于进程
    • 基于事件
    • 基于线程
  • PartB讲并发编程的一些基本概念

    • 共享变量
    • 信号量,进度图和互斥
    • 生产者-消费者问题 和读者写者问题
    • 线程安全,可重入函数
    • 竞争和死锁

PartA

这是第一篇,为Part A

迭代服务器

chap11 说了一个迭代echo服务器,server每一次只能服务于一个客户端。一次只能处理一个请求,只有当前的请求处理完了,才能继续处理下一个。
实用性很小,适用于时间服务器,DHCP服务器等
其处理请求的流程图如下:

迭代服务器.png

只有当 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回收函数

具体可以看代码

代码如下:

void sigchld_handler(int sig){
    // SIGCHLD回收函数
    while (waitpid(-1, 0, WNOHANG) > 0)
        ;
    return;
    // Reap all zombie children
}

int main(int argc, char **argv) {
    int listenfd, connfd;
    socklen_t clientlen;
    struct sockaddr_storage clientaddr;

    Signal(SIGCHLD, sigchld_handler);
    listenfd = Open_listenfd(argv[1]);
    while (1) {
        clientlen = sizeof(struct sockaddr_storage);
        connfd = Accept(listenfd, (SA *) &clientaddr, &clientlen);
        if (Fork() == 0) {
            Close(listenfd); // Child closes its listening socket
            echo(connfd);   // Child services client
            Close(connfd); // Child closes connection with client
            exit(0); // Child exits
        }
        Close(connfd); // 父进程关闭connfd,Parent closes connected socket (important!)
    }
}

 基于进程的优劣

基于进程的方式可以并行处理连接,除了共享已打开的 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函数简述

函数原型如下:


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服务器流程如下:

select服务器.png

其简单的代码如下:

int main(int argc, char **argv) {
  int listenfd, connfd;
  socklen_t clientlen;
  struct sockaddr_storage clientaddr;
  fd_set read_set, ready_set;

  listenfd = Open_listenfd(argv[1]);

  // select 流程
  FD_ZERO(&read_set); /* Clear read set */
  FD_SET(STDIN_FILENO, &read_set);
  /* Add stdin to read set */
  FD_SET(listenfd, &read_set);
  /* Add listenfd to read set */

  while (1) {
    ready_set = read_set;
    // 监听listenfd,可以多个
    select(listenfd + 1, &ready_set, NULL, NULL, NULL);  //

    if (FD_ISSET(STDIN_FILENO, &ready_set)) // line:conc:select:stdinready
      command();                            /* Read command line from stdin */
    if (FD_ISSET(listenfd, &ready_set)) {   // line:conc:select:listenfdready
      clientlen = sizeof(struct sockaddr_storage);
      connfd = accept(listenfd, (SA *)&clientaddr, &clientlen);   // 注意这里accept
      echo(connfd); /* Echo client input until EOF */
      Close(connfd);
    }
  }
}

上面的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
#include <pthread.h>
// 创建新线程
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                          void *(*start_routine) (void *), void *arg);

pthread_t pthread_self(void);  // 返回自己的线程ID
  • thread 为线程 id
  • attr为新线程的属性,一般为NULL
  • start_routine为 欲在新线程上运行的routine
  • arg 表示接受一个新的输入变量
分离线程

线程的状态为两种,
- 可结合(joinable),能够被其他线程回收或杀死
- 分离(detached),不能够被其他线程回收或杀死,由系统自动释放资源

int pthread_detach(pthread_t thread);
终止线程

终止线程有几种方式
- routine 运行完,隐式终止
- 某个对等线程调用exit函数,终止进程及其相关的线程
- 调用pthread_exit 或 pthread_cancle

回收已终止线程的资源

调用pthread_join,回收thread TID的线程

int pthread_join(pthread_t thread, void **retval);
初始化线程

调用pthread_once

基于线程的echo并发编程服务器

// Thread routine
void *thread(void *vargp){
    int connf = *((int *)vargp);
    // detach 之后不用显式 join,会在执行完毕后自动回收
    Pthread_detach(pthread_self());
    Free(vargp);
    echo(connfd);
    // 一定要记得关闭!
    Close(connfd);
    return NULL;
}
int main(int argc, char **argv) {
    int listenfd, *connfdp;
    socklen_t clientlen;
    struct sockaddr_storage clientaddr;
    pthread_t tid;

    listenfd = Open_listenfd(argv[1]);
    while (1) {
        clientlen = sizeof(struct sockaddr_storage);
        // 这里使用新分配的 connected descriptor 来避免竞争条件
        connfdp = Malloc(sizeof(int));
        *connfdp = Accept(listenfd, (SA *) & clientaddr, &clientlen);
        Pthread_create(&tid, NULL, thread, connfdp);
    }
}

在这个模型中,每个客户端由单独的线程进行处理,这些线程除了线程 id 之外,共享所有的进程状态(但是每个线程有自己的局部变量栈)。

使用线程并行,能够在不同的线程见方便地共享数据,效率也比进程高,但是共享变量可能会造成比较难发现的程序问题,很难调试和测试。

总结

一般来说,服务器的实现方式为以上几种.各有其优劣,根据实际情况来决定,现实中多采用基于事件,使用多线程池

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值