• CSAPP 并发编程读书笔记


    CSAPP 并发编程笔记

    并发和并行

    • 并发:Concurrency,只要时间上重叠就算并发,可以是单处理器交替处理
    • 并行:Parallel,属于并发的一种特殊情况(真子集),多核/多 CPU 同时处理

    构造并发程序的方法

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

    • 进程:每个逻辑控制流都是一个进程,由内核调度和维护。
    • I/O 多路复用 :在一个进程上下文中显式地调度他们自己的逻辑流。逻辑流被模型化为状态机
    • 线程:运行在单一进程上下文中的逻辑流,由内核进行调度。可以看作上面两种方式的混合体(内核调度,但共享同一虚拟地址空间)。

    12.1 基于进程的并发编程

    示例代码

    void sigchld_handler(int sig)
    {
        while(waitpid(-1, 0, WNOHANG) > 0) 
            ;
    }
    
    int main()
    {
        signal(SIGCHLD, sigchld_handler); // 回收僵死子进程资源
        listenfd = open_listenfd();
        while (1) {
          connfd = accept(...);
          if (fork() == 0) { // 子进程
            close(listenfd); // 关闭父进程 fd,不关闭问题不大,子进程结束时自动关闭
            process(connfd);
            close(connfd);
            exit(0);
          }
          close(connfd); // 父进程关闭 fd。重要!否则永远不会释放 connfd 连接描述符,导致内存泄漏!
        }
    }
    

    父、子进程中的已连接描述符都指向同一个文件表表项,所以父进程关闭 connfd 至关重要。否则,永远不会释放已连接描述符 4 connfd 的文件表条目,引起内存泄漏。因为 socket 文件表表项中的引用计数,直到父子进程 connfd 都关闭了,到客户端的连接才会终止。

    进程并发优缺点

    共享文件表,但不共享地址空间(是优点,也是缺点)。不方便共享数据,只能通过显式 IPC。进程控制和 IPC 开销大。

    12.2 基于 I/O 多路复用的并发编程

    背景知识

    通过 select 函数,等待一组描述符 ready。

    #include <sys/select.h>
    int select(int n, fd_set *fdset, NULL, NULL, NULL); // 返回 ready fd 的个数,出错返回 -1
    
    FD_ZERO(fd_set *fdset);
    FD_CLR(int fd, fd_set *fdset);
    FD_SET(int fd, fd_set *fdset);
    FD_ISSET(int fd, fd_set *fdset);
    

    select 阻塞,直到至少一个 fd ready(即读取一个字节不阻塞)

    示例代码

    注意:select 有副作用,会修改入参 fdset 的内容!

    fd_set read_set;
    FD_ZERO(&read_set);
    FD_SET(STDIN_FILENO, &read_set);
    FD_SET(listenfd, &read_set);
    
    while(1) {
      fd_set ready_set = read_set; // 因为 select 会修改入参 read_set 的内容,每次都重新从 read_set 拷贝!
      select(listenfd+1, &ready_set, NULL, NULL, NULL);
      if(FD_ISSET(STDIN_FILENO, &ready_set)
         // ...
      if(FD_ISSET(listenfd, &ready_set)
         // ...
    }
    

    I/O 多路复用优缺点

    • 单一进程上下文,共享数据容易。

    • 事件驱动,不需要上下文切换,高效,有明显的性能优势。

    • 编码复杂

    • 不能充分利用多核处理器

    因为明显的性能优势,现代高性能服务器如 Node.js、nginx 和 Tornado 都是基于 I/O 多路复用的事件驱动

    12.3 基于线程的并发编程

    背景知识

    # include <pthread.h>
    typedef void *(func)(void *);
    
    // 创建线程
    int pthread_create(pthread_t *tid, pthread_attr_t *attr, func *f, void *arg);
    // 返回调用者线程 ID
    pthread_t pthread_self();
    // 显示终止线程(threadFunc 返回即隐式终止),如果主线程调用,则等待所有其他对等线程终止,再终止主线程和整个进程
    void pthread_exit(void *thread_return);
    // 对等线程以当前线程 ID 作为参数,调用 pthread_cancel 来终止当前线程
    int pthread_cancel(pthread_t tid); // 成功返回 0
    // 回收已终止线程的资源
    int pthread_join(pthread_t tid, void **thread_return); // 阻塞直到 tid 终止,成功返回 0
    // 分离线程
    int pthread_detach(pthread_t tid); // 成功返回 0
    // 初始化线程 (可以用来实现单例模式)
    pthread_once_t once = PTHREAD_ONCE_INIT; // 必须是全局或者静态变量,固定初始化为 PTHREAD_ONCE_INIT(主要用其地址)
    int pthread_once(pthread_once_t *once_control, void(*init_routine)(void));
    

    线程由内核自动调度,每个线程有自己的线程上下文

    线程上下文:线程 ID(TID)、栈、栈指针、程序计数器、通用目的寄存器和条件码。

    示例代码

    while (1) {
      pConnfd = new int();
      *pConnfd = accept(...);
      pthread_create(&tid, NULL, threadFunc, pConnfd);
    }
    
    void* threadFunc(void* p)
    {
      int connfd = *p;
      pthread_detach(pthread_self());
      free(p);
    }
    

    为了避免 race condition,connfd 必须在堆中创建,在线程中释放,而不能直接把 connfd 的地址传给 threadFunc!

    12.4 多线程共享变量

    线程内存模型

    • 每个线程有独立的线程上下文,共享进程上下文其余部分,包括整个用户虚拟地址空间:只读文本(代码.text)、读/写数据(.bss & .data)、堆、共享库代码和数据。
    • 线程栈不对其他线程设防。

    将变量映射到内存

    • 全局变量:定义在函数之外。仅一个实例@虚拟内存读/写区
    • 本地自动变量:定义在函数内,且没有 static。@虚拟内存线程栈
    • 本地静态变量:定义在函数内,并有 static。仅一个实例@虚拟内存读/写区

    C++11 thread_local 存在哪里?

    12.5 信号量

    背景知识

    • cnt++ 可以细分 3 个子步骤:加载 L、更新 U、储存 S。这三个动作必须一次性完成,不可中断。
    • 进度图不适用于多处理器。
    • P(s):若 s 非零,则 s 减 1,立即返回。若 s 为零,挂起线程,直到 s 变为非零,然后将 s 减 1 返回。
    • V(s):将 s 加 1。如果有线程阻塞,则唤醒这些线程中的某一个。
    • P、V 的加一减一的操作都是原子操作,即 L、U、S 的过程没有中断。
    • 如果有多个线程再等待唤醒,V(s) 只能随机唤醒一个线程,不能指定唤醒哪个线程。
    #include <semaphore.h>
    
    // 成功返回 0,出错返回 -1
    int sem_init(sem_t *sem, 0, unsigned int value);
    int sem_wait(sem_t *sem);  // P(s) 如果 s 为 0,挂起,直到 s 变为非零。V 操作可以重启这个线程。
    int sem_post(sem_t *sem);  // V(s)
    

    生产者-消费者

    int buf[N]; // 理解成 queue<int>
    sem_t mutex, slots, items;
    
    void init(int n)
    {
        sem_init(&mutex, 0, 1);
        sem_init(&slots, 0, n);
        sem_init(&items, 0, 0);
    }
    
    void producer(T item)
    {
        sem_wait(&slots)
        sem_wait(&mutex);
        // insert item into buf
        sem_post(&mutex);
        sem_post(&items);
    }
    
    T consumer()
    {
        T item;
        sem_wait(&items);
        sem_wait(&mutex);
        // pop item from buf
        sem_post(&mutex);
        sem_post(&slots);
        return item;
    }
    

    读者-写者

    读者、写者平等地争夺 w,一旦读者获取了 w,将一直占有 w,直到最后一个读者离开,释放 w。

    如果读者不断到达,写者可能无限等待,导致饥饿

    以下是一个读者优先的例子。(弱优先级,当最后一个读者释放 w,下一个获取 w 的不一定是等待 w 的读者,也有可能是等待 w 的写者!)

    int readcnt = 0;
    sem_t mutex; // 保护 readcnt
    sem_t w;     // 读者或写者抢占 w
    
    void init()
    {
        sem_init(&mutex, 0, 1);
        sem_init(&w, 0, 1);
    }
    
    void reader()
    {
        while(1)
        {
            sem_wait(&mutex);
            readcnt++;
            if(readcnt == 1)
                sem_wait(&w); // 如果这是第一个读者,抢占 w
            sem_post(&mutex);
          
            // Critical section
            // Reading...
          
            sem_wait(&mutex);
            readcnt--;
            if(readcnt == 0)
                sem_post(&w); // 如果这是最后一个读者,释放 w
            sem_post(&mutex);
        }
    }
    
    void writer()
    {
        while(1)
        {
            sem_wait(&w);
                
            // Critical section
            // Writing...
            
            sem_post(&w);
        }
    }
    

    综合:基于预线程化的并发服务器

    很好的例子,结合上述多种方式的优点,建议亲自写一遍。代码参考 CSAPP,不再赘述。

    12.6 使用线程提高并行性

    通用技术:向对等线程传递一个小整数,作为唯一的线程 ID。每个对等线程根据线程 ID 来决定它应该计算序列的哪一部分。

    通常每个核上运行一个线程,在一个核上运行运行多个线程会有额外的上下文切换开销。

    多线程求和的例子:

    线程数 1 2 4 8 16
    sum_mutex 68.00 432.00 719.00 552.00 599.00
    sum_global 7.26 3.64 1.91 1.85 1.84
    sum_local 1.06 0.54 0.28 0.29 0.30
    // 加锁操作全局变量
    void* sum_mutex(void* vargp)
    {
        // 根据 vargp 确定计算范围
        for(i=start; i<end; i++) {
            sem_wait(&mutex);
            gsum += i;
            sem_post(&mutex);
        }
        return NULL;
    }
    
    // 每个线程独立位置存放结果,无需 mutex,直接累加到全局数组。主线程等待所有子线程完成
    void* sum_global(void* vargp)
    {
        long threadId = *((long*) vargp);
        // 根据 vargp 确定计算范围
        for(i=start; i<end; i++)
            gsum[threadId] += i;
        return NULL;
    }
    
    // 先用局部变量累加结果,减少不必要的内存引用,最后一次性赋给全局数组
    void* sum_local(void* vargp)
    {
        // 根据 vargp 确定计算范围
        int local_sum = 0;
        for(i=start; i<end; i++) {
            local_sum += i;
        }
        gsum[threadId] = local_sum;
        return NULL;
    }
    

    12.7 其他并发问题

    线程安全

    线程安全(thread-safe):多个并发线程反复调用,结果正确

    可重入(reentrant):线程安全的真子集,不需要同步操作,比不可重入的线程安全的函数更高效。

    四类不相交的线程不安全函数:

    不安全类 说明 例子 变为线程安全的方法
    1 不保护共享变量的函数 - 同步操作保护共享变量;缺点:慢
    2 保持跨越多个调用的状态的函数 伪随机数生成器:当前调用结果依赖前次调用的中间结果 唯一方式是重写。不再依赖 static 数据,而是依靠调用者在参数中传递状态
    3 返回指向静态变量的指针的函数 ctime、gethostbyname:将结果保存在 static 变量中,然后返回这个变量的指针 a) 重写:调用者传递存放结果的变量地址; b) 如果难以修改,则创建包装函数,进行加锁-复制。
    4 调用线程不安全函数的函数 f 调用线程不安全函数 g 如果 g 是第 2 类,只能重写 g;如果 g 是 1、3 类,可以加锁(拷贝)

    Linux 中线程不安全函数

    大多数 Linux 函数都是线程安全的,包括定义在 C 库中的函数(例如 malloc、free、realloc、printf、scanf)

    线程不安全函数 线程不安全类 Linux 线程安全版本
    rand 2 rand_r
    strtok(已弃用) 2 strtok_r
    asctime 3 asctime_r
    ctime 3 ctime_r
    gethostbyaddr(已弃用,推荐 getaddrinfo) 3 gethostbyaddr_r
    gethostbyname(已弃用,推荐 getnameinfo) 3 gethostbyname_r
    net_ntoa(已弃用,推荐 inet_ntop) 3
    localtime 3 localtime_r

    死锁

    • 分析工具:进度图
    • 一个死锁的例子:线程 A 持有 mutex1,等待 mutex2;线程 B 持有 mutex2,等待 mutex1
    • 避免死锁的最简单方式——互斥锁加锁顺序规则:给定所有互斥操作的一个全序,如果每个线程都是以一种顺序获得互斥锁并以相反的顺序释放,那么这个程序就是无死锁的。

    注:现代 C++ 可以一次获得多个锁,从根源上避免了死锁。

    12.8 小结

    • 并发:时间上重叠的逻辑流
    • 三种并发机制:进程、I/O 多路复用和线程
      • 进程由内核调度,独立虚拟地址空间,只能显式 IPC 共享数据
      • 事件驱动程序有自己的并发逻辑流(模型化为状态机),用 I/O 多路复用来显式调度这些流
      • 线程:内核自动调度,单一进程上下文
    • 信号量解决共享数据的并发访问问题,提供互斥访问,也支持生产者-消费者、读者-写者
    • 被线程调用的函数必须线程安全,有四类线程不安全的函数
    • 可重入函数比不可重入函数更高效,因为不需要任何同步操作
    • 小心竞争和死锁

    Reference

  • 相关阅读:
    Case study, about cnblogs
    《Windows用户态程序高效排错》
    为什么java+winform就那么慢呢
    Mixed DLL Loading analysis
    <a>标签无跳转
    各情景下元素宽高的获取
    在Asp.Net中使用FCKeditor的常用配置
    Small Program 1.0 发布
    微软会向开发者收费吗?
    BO入门实战
  • 原文地址:https://www.cnblogs.com/tengzijian/p/15680955.html
Copyright © 2020-2023  润新知