【深入理解计算机系统】第12章 并发编程

【深入理解计算机系统】第12章 并发编程

如果逻辑控制流在时间上重叠,那么他们就是并发的。使用应用级并发的应用程序称为并发程序。

并发的应用:

  • 访问慢速I/O设备。当一个应用正在等待来自慢速I/O设备(如磁盘)的数据到达时,内核会运行其他进程,使CPU保持繁忙。

  • 与人交互。和计算机交互的人要求计算机有同时执行多个任务的能力。如,我们在打印一个文档时,可能想要调整一个窗口的大小。

  • 通过推迟工作以降低延迟。有时,应用程序能够通过推迟其他操作和并发地执行它们,利用并发来降低某些操作的延迟。例如,一个动态内存分配器可以通过推迟合并,把它放到一个运行在较低优先级上的并发“合并”流中,在有空闲的CPU周期时充分利用这些空闲周期,从而降低单个free操作的延迟。

  • 服务多个网络客户端。创建一个并发服务器,它为每个客户创建一个单独的逻辑流。允许服务器同时为多个客户端服务,也避免了慢速客户端独占服务器。

  • 在多核机器上进行并行计算。多核处理器包含多个CPU,可以同时处理多个应用程序

构造并发程序的方法:

  • 进程。进程有独立的虚拟地址空间,想要和其他流通信,控制流必须使用某种显式地进程间通信机制。

  • I/O多路复用。应用程序在一个进程地上下文中显式地调度它们自己地逻辑流。逻辑流被模型化为状态机,数据到达文件描述符后,主程序显式地从一个状态转换到另一个状态。因为程序是单独地进程,所有所有地流都共享同一个地址空间

  • 线程。线程是运行在一个单一进程上下文中地逻辑流,由内核进行调度。

1.基于进程地并发编程

构造并发程序最简单地方法就是用进程。

1)服务器接受客户端的连接请求:服务器正在监听一个监听描述符3上的连接请求,接受客户端1的连接请求后返回一个已连接描述符4

2)服务器派生一个子进程为这个客户端服务:接受连接后,服务器派生一个子进程,此子进程获得服务器描述符表的完整副本,子进程关闭它的副本中的监听描述符3,父进程关闭它的已连接描述符4的副本(父,子进程中已连接描述符都指向同一个文件表项,父进程关闭已连接描述符的副本至关重要,否则将永不会释放已连接描述符4的文件表条目,由此引起的内存泄露将最终消耗光可用的内存,是系统崩溃)

3)服务器接受另一个连接请求:服务器监听一个监听描述符3上的连接请求,接受客户端2的连接请求后返回一个已连接描述符5

4)服务器派生另一个子进程为新的客户端服务:父进程派生另一个子进程,这个子进程用已连接描述符5为客户端2服务,此时,父进程等待下一个连接请求,两个子进程正并发地为它们各自的客户端服务。

1.1基于进程的并发服务器

例程:

基于进程的并发echo服务器,父进程派生一个子进程来处理每个新的连接请求

#include"csapp.h"
void echo(int);
void sigchld_handler(int sig)
{
    while(waitpid(-1,0,WNOHANG)>0)
        ;
    return;
}
int main(int argc,char **argv)
{
    int listenfd,connfd;
    socklen_t clientlen;
    struct sockaddr_storage clientaddr;
    
    if(argc != 2)
    {
        fprintf(stderr,"usage:%s<port>\n",argv[0]);//将信息错误写入strerr中
        exit(0);
    }
    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);//子进程关闭监听描述符
            echo(connfd);
            close(connfd);//子进程关闭已连接的描述符
            exit(0);
        }
        close(connfd);//父进程关闭已连接的描述符
    }
}
void echo(int connfd)//反复读写文本行,直到读到EOF
{
    size_t n;
    char buf[MAXLINE];
    rio_t rio;
    
    rio_readinitb(&rio,connfd);//初始化函数
    while((n=rio_readlineb(&rio,buf,MAXLINE))!=0){
        printf("server received %d bytes\n",(int)n);
        rio_writen(connfd,buf,n);
    }
}
   

说明:

  • 通过用signal函数回收僵死子进程的资源

  • 父子进程关闭各自的connfd副本,避免内存泄漏

  • 因为套接字的文件表表项中的引用计数,直到父子进程的connfd都关闭了,到客户端的连接才会终止

1.2进程的优劣

优势:

父子进程共享状态信息,进程由一个非常清晰的模型:共享文件表,但不共享用户地址空间。所以一个进程不可能覆盖另一个进程的虚拟内存

劣势:

  • 独立的地址空间使得进程共享状态信息变得更加困难,为了共享信息,它们必须使用显式的IPC(进程间通信)机制。

  • 基于进程的设计,往往比较慢,因为进程控制和IPC的开销很高

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

I/O多路复用技术的基本思路是使用select函数,要求内核挂起进程,只有在一个或多个I/O事件发生后,才将控制返回给应用程序。

#include<sys/select.h>
int select(int n,fd_set *fdset,NULL,NULL);//返回已准备好的描述符的非零的个数,出错-1
//处理描述符集合的宏
FD_ZERO(fd_set *fdset);//清空fdset中的所有描述符
FD_CLR(int fd,fd_set *fdset);
FD_SET(int fd,fd_set *fdset);//将fd描述符加入fdset集合中
FD_ISSET(int fd,fd_set *fdset);//查看哪个描述符准备好

例程:

使用多路复用的迭代echo服务器,服务器使用select等待监听描述符上的连接请求和标准输入上的命令

#include"csapp.h"
void echo(int connfd);
void command(void);
​
int main(int argc,char **argv)
{
    int listenfd,connfd;
    socklen_t clientlen;
    struct sockaddr_storage clientaddr;
    fd_set read_set,ready_set;
    if(argc != 2)
    {
        fprintf(stderr,"usage:%s<port>\n",argv[0]);
        exit(0);
    }
    listenfd=open_listenfd(argv[1]);
    
    FD_ZERO(&read_set);//将read_set描述符集合清零
    FD_SET(STDIN_FILENO,&read_set);//将标准输入描述符0加入read_set描述符集合
    FD_SET(listenfd,&read_set);//将listenfd监听描述符加入read_set描述符集合
    
    while(1)
    {
        ready_set=read_set;
        select(listenfd+1,&ready_set,NULL,NULL,NULL);//select函数会一直阻塞直到read_set集合中至少有一个描述符准备好可以读
        if(FD_ISSET(STDIN_FILENO,&ready_set))//如果标准输入0准备好
            command();
        if(FD_ISSET(listenfd,&ready_set)){//如果监听描述符准备好
            clientlen=sizeof(struct sockaddr_storage);
            connfd=accept(listenfd,(SA *)&clientaddr,&clientlen);
            echo(connfd);//反复读写文本行,直到读到EOF
            close(connfd);
        }
    }
}
void command(void){
    char buf[MAXLINE];
    if(!fgets(buf,MAXLINE,stdin))
        exit(0);
    printf("%s",buf);
}
void echo(int connfd)//反复读写文本行,直到读到EOF
{
    size_t n;
    char buf[MAXLINE];
    rio_t rio;
    
    rio_readinitb(&rio,connfd);//初始化函数
    while((n=rio_readlineb(&rio,buf,MAXLINE))!=0){
        printf("server received %d bytes\n",(int)n);
        rio_writen(connfd,buf,n);
    }
}

2.1基于I/O多路复用的并发事件驱动服务器

I/O多路复用可以用做并发事件驱动程序的基础,在事件驱动程序中,某些事件会导致流向前推进。一般的思路是将逻辑流模型化为状态机。不严格的说,一个状态机就是一组状态,输入事件和转移,其中转移是将状态和输入事件映射到状态。

2.2I/O多路复用技术的优劣

优点:

  • 它比基于进程的设计给了程序员更多的对程序行为的控制。

  • 一个基于I/O多路复用的事件驱动服务器是运行在单一进程上下文中的,因此每个逻辑流都能访问该进程的全部地址空间。使得流之间共享数据变得容易。

缺点:

  • 编码复杂,事件驱动的并发echo服务器需要的代码比基于进程的服务器多三倍。

3.基于线程的并发编程

线程就是运行在进程上下文中的逻辑流。每个线程都有自己的线程上下文,包括一个唯一的整数线程ID、栈、栈指针、程序计数器、通用目的寄存器和条形码。所有运行在一个进程里的线程共享该进程的整个虚拟地址空间。

基于线程的逻辑流结合了基于进程和基于I/O多路复用的流的特性。

  • 同进程一样,线程由内核自动调度,并且内核通过一个整数ID来识别线程。

  • 同基于I/O多路复用的流一样,多个线程运行在单一进程的上下文中,共享这个进程虚拟地址空间的所有内容,包括它的代码、数据、堆、共享库和打开的文件

3.1线程执行模型

在某一时刻,主线程创建一个对等线程,然后两个线程并发的执行,最后,因为主线程执行一个慢速系统调用,如read或sleep,控制就会通过上下文切换传递到对等线程。对等线程执行一段时间,然后控制传递回主线程,依次类推。如图所示:

3.2Posix线程

Posix线程(Pthreads)是在c程序中处理线程的一个标准接口。Pthreads定义了大约60个函数,允许程序创建、杀死和回收线程,与对等线程安全的共享数据,还可通知对等线程系统状态的变化。

4.用信号量同步线程

4.1信号量

一种经典的解决同步不同执行线程问题的方法,基于一种叫做信号量的特殊类型变量。

信号量为非负整数,只能由两种特殊的操作来处理,这两种操作为P和V:

  • P(s):如果s非零,那么P将s减1,并立即返回。如果s为零,那么就挂起这个线程,直到s为非零,而一个V操作回重启这个线程。重启之后,P操作将s减1,并将控制返回给调用者

  • V(s):V操作将s加1,如果由任何线程阻塞在P操作等待s变成非零,那么V操作会重启这个线程中的一个,然后该线程将s减1,完成P操作

P中的测试和减1操作是不可分割的,一旦预测信号量s变成非零,就会将s减1,不能有中断。

V中的加1操作也是不可分割的,也就是加载、加1和存储信号的过程中没有中断。

Posix标准定义了操作信号量的函数:

#include<semaphore.h>
int sem_init(sem_t *sem,0,unsigned int value);//初始化信号量,成功返回0,出错-1
void P(sem_t *s);
void V(sem_t *s);

4.2使用信号量实现互斥

信号量提供一种很方便的方法来确保对共享变量的互斥访问。基本思想是将每个共享变量与一个信号量s联系起来,然后用P(s)和V(s)操作将相应的临界区包围起来。

这种方式来保护共享变量的信号量叫做二元信号量,因为它的值总是0和1。以提供互斥为目的的二元信号量也称为互斥锁。在一个互斥锁上执行P操作称为对互斥量加锁。执行V操作称为对互斥量解锁。一个被用作一组可用资源的计数器的信号量被称为计数信号量。

如图进度图展示了如何利用二元信号量来正确的同步计数器程序示例:

P和V操作的结合创建了一组状态,叫做禁止区,其中s<0。

例:

volatile long int cnt =0;
sem_t mutex;
sem_init(&mutex,o,1);//将mutex初始化为1
for(i=0;i<10;i++)
{
    P(&mutex);
    cnt++;
    V(&mutex);
}

4.3利用信号量来调度共享资源

信号量的另一个作用是对调度对共享资源的访问。一个线程用信号量操作来通知另一个线程,程序状态中的某个条件已经为真了。两个经典例子是生产者-消费者读者-写者问题

(1)生产者-消费者问题

生产者和消费者线程共享一个有n个槽的有限缓冲区。生产者线程反复生成新的项目,并把它们插入缓冲区,消费者线程不断地从缓冲区中取出这些项目,然后使用它们

因为插入和取出项目都涉及更新共享数据,所以我们必须保证对缓冲区地访问是互斥地,另外,如果缓冲区是满地。那么生产者必须必须等待直到一个槽位变得可用。同理如果缓冲区是空地,那么消费者必须等待直到有一个项目变为可用。

(2)读者-写者问题

读者-写者问题是互斥问题地一个概况。有些线程只读对象,有的只修改是修改对象。对两种读者-写者问题的正确解答可能导致饥饿,饥饿就是一个线程无限期地阻塞,无法进展。

5.其他并发问题

5.1线程安全

当用线程编写程序时,必须小心的编写那些具有称为线程安全性属性的函数。

一个函数被称为线程安全的,当且仅当被多个并发线程反复地调用时,他会一直产生正确的结果。如果一个函数不是线程安全的,我们就说它是线程不安全的。

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

  • 不保护共享变量的函数,变为线程安全的方法:利用P和V操作这样的同步操作来保护共享变量

  • 保持跨越多个调用的状态的函数,一个伪随机数生成器是这类线程不安全函数的例子。rank函数是线程不安全的,因为当前调用的结果依赖于前次调用的中间结果

  • 返回指向静态变量的指针的函数。例:c和i将计算结果放在一个static变量中,然后返回一个指向这个变量的指针。如果从并发线程中调用这些函数,那么将发生问题,因为正在被一个线程使用的结果会被另一个线程悄悄地覆盖了。

    解决方法:1)重写函数,使得调用者传递存放结果的变量的地址,这就消除了所有的共享数据。2)加锁-复制技术,调用互斥量加锁,将函数返回的结果复制到一个私有的内存位置。

  • 调用线程不安全函数的函数

5.2可重入性

可重入函数:当它们被多个线程调用时,不会引用任何共享数据,是一类重要的线程安全函数。

5.3死锁

死锁:一组线程被阻塞了,等待一个永远不会为真的条件。解决死锁的方法:每个线程先对互斥量A加锁,再对互斥量B加锁

猜你喜欢

转载自blog.csdn.net/zhangxiafll/article/details/81169449
今日推荐