六星经典CSAPP-笔记(12)并发编程(上)

六星经典CSAPP-笔记(12)并发编程(上)

1.并发(Concurrency)

我们常常在不知不觉间就说到或使用并发,但从未深入思考并发。我们常常能“遇见”并发,由于并发不仅仅是操作系统内核的“绝招”,它也是应用开发中不可缺少的技巧:

  • 訪问慢I/O设备:就像当应用程序等待I/O中的数据时内核会切换运行其它进程一样,我们的应用也能够用相似的方式,将I/O请求与其它工作重叠从而挖掘并发的潜能。
  • 推迟工作而降低延迟:我们能够推迟一些耗时工作稍后运行。比如内存分配器不在free时整理碎片,而是将这些琐屑的工作推迟到一个稍后运行的独立“逻辑流”(logical flow)中。
  • 服务多个网络客户端:假设没有并发。一个慢客户端将会堵塞整个Web服务器。这是不现实的。所以我们须要为每一个客户端都创建独立的逻辑流,避免某个客户端独占整个服务器。
  • 多核上并行计算:将程序切分成多个逻辑流并发运行。在多核环境中可能被分配到不同的核心上运行,从而实现了并行。

现代操作系统提供三种基本方法构建上述这些并发程序:

  • 进程(Process):进程由操作系统维护和调度,每一个进程都有独立的虚拟地址空间,进程间交互要靠IPC机制。
  • I/O多路复用(I/O multiplexing):在进程的上下文中。应用程序“显式地”调度自己的逻辑流。逻辑流被构建为状态机,当文件描写叙述符上有数据到达时,main程序显式地将它从一种状态转移到还有一种状态。
  • 线程(Thread):线程运行在进程的上下文中,由操作系统调度。能够将线程看做是进程和I/O多路复用技术的混合,它既像进程一样被操作系统调度,又能像I/O多路复用一样共享虚拟地址空间。

2.基于进程的并发

构建并发程序的最简单方式就是使用进程。每接收到一个连接,父进程都会fork出一个子进程。

由于子进程拷贝了父进程的文件描写叙述符表,所以使用进程构建并发时一定要避免资源泄露,尤其是父进程。

子进程一定要关闭监听描写叙述符listenfd,这个描写叙述符在子进程中是没有不论什么用处的。而相同的。父进程要负责关闭连接描写叙述符connfd,同理。这个描写叙述符在父进程中也是一点用处都没有。

2.1 演示样例程序

以下是一个使用进程构建并发Web服务器的小样例,用telnet localhost 7777连接上后能看到服务端发送过来的”helloworld”欢迎语。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>

void sigchld_handler(int sig);
int open_serverfd(int port);

int main(int argc, char const *argv[])
{
    int listenfd, connfd;
    socklen_t clientlen = sizeof(struct sockaddr_in);
    struct sockaddr_in clientaddr;

    // 1.Register signal handler
    signal(SIGCHLD, sigchld_handler);

    // 2.Open socket decriptor
    listenfd = open_serverfd(7777);
    printf("Main: start...
");

    // 3.Start service loop
    while(1) {      
        connfd = accept(listenfd, (struct sockaddr *)&clientaddr, &clientlen);
        printf("Main: accept client %s
", inet_ntoa(clientaddr.sin_addr));

        if (fork() == 0) {
            // Child process flow:
            // 1.Close parent resource
            close(listenfd);
            // 2.Individule logic
            char buf[11] = "helloworld";
            write(connfd, buf, strlen(buf));
            // 3.Close own resource
            close(connfd);
            exit(0);
        }
        close(connfd);
    }
    return 0;
}

void sigchld_handler(int sig)
{
    while(waitpid(-1, 0, WNOHANG) > 0)
        ;
}

int open_serverfd(int port)
{
    int listenfd, optval = 1;
    struct sockaddr_in sockaddr;

    // 1.Create socket address
    memset(&sockaddr, 0, sizeof(sockaddr));
    sockaddr.sin_family = AF_INET;
    sockaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    sockaddr.sin_port = htons(port);

    // 2.Create socket of specific protocal
    if ((listenfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
        fprintf(stderr, "Error: %s
", strerror(errno));
        return -1;
    }

    // 3.Eliminates "Address already in use" error from bind
    if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, 
                    &optval, sizeof(int)) < 0) {
        fprintf(stderr, "Error: %s
", strerror(errno));
        return -2;
    }

    // 3.Bind socket and address
    if (bind(listenfd, (struct sockaddr *)&sockaddr, sizeof(sockaddr)) < 0) {
        fprintf(stderr, "Error: %s
", strerror(errno));
        return -3;
    }

    // 4.Start to listen for connection requests with backlog queue 10
    if (listen(listenfd, 10) < 0) {
        fprintf(stderr, "Error: %s
", strerror(errno));
        return -4;
    }

    return listenfd;
}

2.2 Pros & Cons

基于进程的并发的长处就是模型清晰。子进程的虚拟地址空间独立,不会互相干扰。但这也是它的缺点。假设要共享数据则必须使用IPC(interprocess communication,广义上讲Socket、信号、waitpid()都算,但一般IPC指pipe管道、FIFO队列、System V共享内存、信号量等)机制。此外,由于进程控制和IPC的开销都非常高。所以基于进程的方式可能会非常慢。

SIGCHLD信号
假设父进程不等待子进程结束。子进程将成为僵尸进程(zombie process)从而占用系统资源。因此上面的样例中我们注冊了SIGCHLD信号的处理函数。


“The SIGCHLD signal is sent to the parent of a child process when it exits, is interrupted, or resumes after being interrupted. By default the signal is simply ignored.” - Wikipedia
这里有一个小技巧。假设父进程等待子进程结束。将添加父进程的负担。影响服务器进程的并发性能。

Web服务器为了保证高性能。通常会将此信号的处理方式设为忽略。让内核把僵尸子进程转交给init进程去处理。忽略设置方式为:signal(SIGCHLD,SIG_IGN);

3.基于I/O多路复用的并发

如今对我们的Web服务器进行升级。让它不仅能够处理远程客户端的连接请求。写回helloworld欢迎语。同一时候它还能响应当前命令行中的用户输入。那么。我们应该先等待哪种事件?实际上先等待谁都不是最理想的,由于堵塞地等待一个必定导致无法响应还有一个。解决这样的困境的技术就是I/O多路复用。

3.1 select()

我们用select()函数请求内核挂起当前进程。仅仅有当我们关心的I/O事件发生时再将控制返回给我们的应用程序。以下就是select()函数的原型及操作描写叙述符集合的宏。我们主要关注的就是select()的前两个參数:基数n(cardinality。集合中最大描写叙述符的值加1)和描写叙述符集合(fdset or read set)。

#include <unistd.h>
#include <sys/types.h>

// Returns nonzero count of ready descriptors, −1 on error
int select(int n, fd_set *fdset, NULL, NULL, NULL);

// Macros for manipulating descriptor sets
FD_ZERO(fd_set *fdset); /* Clear all bits in fdset */
FD_CLR(int fd, fd_set *fdset); /* Clear bit fd in fdset */
FD_SET(int fd, fd_set *fdset); /* Turn on bit fd in fdset */
FD_ISSET(int fd, fd_set *fdset); /* Is bit fd in fdset on? */

select()函数实际非常复杂。以下先看一段代码,结合实际代码来理解select()和I/O多路复用是怎样使用的。

3.2 演示样例程序

以下就是升级后的服务端代码。利用I/O多路复用,我们既能响应远程客户端请求。又能响应命令行输入。注意两点:一是使用I/O多路复用后,我们不再直接调用accept()函数,而是堵塞在select()函数的调用上。二是select()返回后我们要用if逐一推断各个描写叙述符。由于可能有多个描写叙述符的事件发生了,所以不能用if-else做推断。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/select.h>

int open_serverfd(int port);

int main(int argc, char const *argv[])
{
    int listenfd, connfd;
    socklen_t clientlen = sizeof(struct sockaddr_in);
    struct sockaddr_in clientaddr;
    fd_set readset, readyset;

    // 1.Open socket decriptor
    listenfd = open_serverfd(7777);
    printf("Main: start...
");

    // 2.Prepare descriptor set
    FD_ZERO(&readyset);
    FD_SET(STDIN_FILENO, &readset);
    FD_SET(listenfd, &readset);

    // 3.Start service loop
    while(1) {
        readyset = readset;
        select(listenfd+1, &readyset, NULL, NULL, NULL);        

        if (FD_ISSET(STDIN_FILENO, &readyset)) {
            char buf[100];
            if (!fgets(buf, 100, stdin))
                exit(0);
            if (!strncmp(buf, "quit", 4))
                exit(0);
            printf("%s
", buf);
        }

        if (FD_ISSET(listenfd, &readyset)) {
            connfd = accept(listenfd, (struct sockaddr *)&clientaddr, &clientlen);
            printf("Main: accept client %s
", inet_ntoa(clientaddr.sin_addr));

            char buf[11] = "helloworld";
            write(connfd, buf, strlen(buf));
            close(connfd);
        }       
    }
    return 0;
}

int open_serverfd(int port)
{
    int listenfd, optval = 1;
    struct sockaddr_in sockaddr;

    // 1.Create socket address
    memset(&sockaddr, 0, sizeof(sockaddr));
    sockaddr.sin_family = AF_INET;
    sockaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    sockaddr.sin_port = htons(port);

    // 2.Create socket of specific protocal
    if ((listenfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
        fprintf(stderr, "Error: %s
", strerror(errno));
        return -1;
    }

    // 3.Eliminates "Address already in use" error from bind
    if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, 
                    &optval, sizeof(int)) < 0) {
        fprintf(stderr, "Error: %s
", strerror(errno));
        return -2;
    }

    // 3.Bind socket and address
    if (bind(listenfd, (struct sockaddr *)&sockaddr, sizeof(sockaddr)) < 0) {
        fprintf(stderr, "Error: %s
", strerror(errno));
        return -3;
    }

    // 4.Start to listen for connection requests with backlog queue 10
    if (listen(listenfd, 10) < 0) {
        fprintf(stderr, "Error: %s
", strerror(errno));
        return -4;
    }

    return listenfd;
}

FD_ZERO()清空readset:

listenfd stdin
3 2 1 0
0 0 0 0

FD_SET()设置readset:

listenfd stdin
3 2 1 0
1 0 0 1

假设控制台有输入,则select()“苏醒”并返回readset:

listenfd stdin
3 2 1 0
0 0 0 1

此时我们用FD_ISSET()就能检測到stdin已准备好读。

一切看起来都非常完美,但细致看一下select()的循环就会发现问题。

假设远程客户端发送连接请求。select()返回进入客户端处理逻辑。由于这段逻辑是同步运行的。所以 在select()函数返回到处理完客户端请求的这段时间内,我们没有再次调用select(),因此这段时间内我们是无法响应的

3.3 更好的并发

解决上面问题的方法就是在更细的粒度上多路复用I/O。基本思想是:将I/O多路复用作为事件驱动程序的基础框架。将程序的整个运行流程划分为不同的事件,在多路复用机制上构建起状态机。我们通常常使用有向图代表状态机。用结点代表状态,用有向弧代表状态的迁移,用弧上标签文字代表促使迁移的输入事件。

每一个输入事件都触发了从当前状态到下一状态的迁移。

以以下样例为例,当接收到新客户端连接时。为其创建一个状态机。并关联到已连接的描写叙述符connfd上。当connfd准备好读写时。我们写回欢迎语,完毕状态的转换。所以与前面第一版的样例有些差别。当我们用telnet连接上时。要敲入一个字符才干看到欢迎语。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/select.h>

typedef struct {
    int     maxfd;
    fd_set  readset;
    fd_set  readyset;
    int     nready;
    int     maxi;
    int     clientfd[FD_SETSIZE];
} pool;

void init_pool(int listenfd, pool *p);
void add_client(int connfd, pool *p);
void check_clients(pool *p);

int open_serverfd(int port);


int main(int argc, char const *argv[])
{
    int listenfd, connfd;
    socklen_t clientlen = sizeof(struct sockaddr_in);
    struct sockaddr_in clientaddr;
    static pool pool;

    listenfd = open_serverfd(7777);
    init_pool(listenfd, &pool);
    printf("Main: start...
");

    while(1) {
        // 1.Wait for listening/connected descriptor ready
        pool.readyset = pool.readset;
        pool.nready = select(pool.maxfd+1, &pool.readyset, NULL, NULL, NULL);

        // 2.Add new client descriptor to pool if listening descriptor ready
        if (FD_ISSET(listenfd, &pool.readyset)) {
            connfd = accept(listenfd, (struct sockaddr *)&clientaddr, &clientlen);
            add_client(connfd, &pool);
        }

        // 3.Echo welcome text if connected descriptor ready
        check_clients(&pool);       
    }
    return 0;
}

void init_pool(int listenfd, pool *p)
{
    int i;

    // 1.Initially, there're no connected descriptor
    p->maxi = -1;
    for (i = 0; i < FD_SETSIZE; ++i)
        p->clientfd[i] = -1;

    // 2.Initially, listening descriptor is only member of read set
    p->maxfd = listenfd;
    FD_ZERO(&p->readset);
    FD_SET(listenfd, &p->readset);
}

void add_client(int connfd, pool *p)
{
    int i;

    p->nready--;
    for (i = 0; i < FD_SETSIZE; ++i) {
        if (p->clientfd[i] < 0) {
            // 1.Add connected descriptor to the pool
            p->clientfd[i] = connfd;

            // 2.Add it to descriptor set
            FD_SET(connfd, &p->readset);

            // 3.Update max descriptor
            if (connfd > p->maxfd)
                p->maxfd = connfd;
            if (i > p->maxi)
                p->maxi = i;
            break;
        }
    }
}

void check_clients(pool *p)
{
    int i, connfd;

    for (i = 0; i <= p->maxi && p->nready > 0; ++i) {
        connfd = p->clientfd[i];

        if (connfd > 0 && FD_ISSET(connfd, &p->readyset)) {
            p->nready--;

            char buf[11] = "helloworld";
            write(connfd, buf, strlen(buf));
            close(connfd);

            FD_CLR(connfd, &p->readset);
            p->clientfd[i] = -1;
        }
    }
}

int open_serverfd(int port) { ... }

3.4 高级知识

以下扩展一下知识面。环绕I/O多路复用,介绍一些周边的、但非常重要的知识。主要參考资料有:

3.5.1 select vs. poll vs. epoll

select最早出现于1983年的BSD 4.2中。其长处是跨平台性比較好。差点儿全部平台都支持它。而其缺点是:select轮询(线性扫描)全部Socket描写叙述符,检查是否有事件发生。当描写叙述符非常多时,select的耗时也会添加。因此select能够监视的描写叙述符数目是有上限的,上限就是FD_SETSIZE常量。此外,select每次都会在内核态和用户态之间复制整个描写叙述符数组,而无论这里面是仅仅有一个还是几个描写叙述符有事件发生。

poll诞生于System V 3,它与select没有本质上差别,仅仅只是poll没有描写叙述符最大数量的限制。

Linux 2.6后提供了更加高效的epoll机制,被公觉得眼下性能最好的多路I/O就绪通知方法。

它有两项本质上的改进:

  • epoll採用基于事件的就绪通知方式。在select/poll中,进程仅仅有在调用select()和poll()后。内核才对全部监视的文件描写叙述符进行扫描。

    而epoll事先通过epoll_ctl()注冊一个文件描写叙述符,一旦基于某个文件描写叙述符就绪时。内核会採用相似callback的回调机制。迅速激活这个文件描写叙述符,当进程调用epoll_wait()时便得到通知。

  • epoll採用内存映射避免复制开销。当我们调用epoll_wait()获得就绪文件描写叙述符时,返回的不是实际的描写叙述符。而是一个代表就绪描写叙述符数量的值。你仅仅须要去epoll指定的一个数组中依次取得对应数量的文件描写叙述符就可以。这里也使用了内存映射(mmap)技术,这样便彻底省掉了这些文件描写叙述符在系统调用时复制的开销。

kqueue是BSD系统中的事件通知机制。不在这里讨论了。”Kqueue is a scalable event notification interface introduced in FreeBSD 4.1”.

3.5.2 水平触发和边际触发

我们知道,select()和poll()函数会将准备就绪的文件描写叙述符通知给进程。假设进程没有对其进行处理(I/O操作),那么下次调用select()和poll()时将再次报告这些文件描写叙述符。所以select和poll机制一般不会丢失就绪的描写叙述符消息,这样的方式称为 水平触发(Level Triggered)

epoll能够同一时候支持水平触发和边际触发。所谓 边际触发(Edge Triggered)是指:仅仅告诉进程哪些文件描写叙述符刚刚变为就绪状态。并且仅仅说一遍。假设我们没有採取行动,那么它将不会再次告知。理论上边缘触发的性能要更高一些。可是代码实现起来要非常小心。

3.5.3 Proactor和Reactor模式

(待补充)

3.5.4 事件通知库

眼下比較流行的事件通知库有libevent、libev、libuv:

  • libevent独立于操作系统平台,它支持poll,kqueue,POSIX/Windows select。epoll机制。

    它採用Reactor模型,将这些内部机制全然隐藏在它暴露的API之后。libevent被广泛应用于非常多程序,如Chromium、Memcached、Transmision、Vomit等等。

  • libev与libevent相似。但开销更低。

  • libuv是node.js作者做的一个封装库。

像Nginx、Redis等高性能中间件,出于简洁、性能、以及作者的“代码洁癖”方面的考虑,没有使用libevent等第三方事件通知库,而是自己动手实现了相似的简化版。

3.5 Pros & Cons

由于没有上下文切换,所以基于I/O多路复用的并发要比基于进程的高效得多。

并且由于是单线程运行的,所以调试起来也非常方便,直接用GDB等工具就能调错。

I/O多路复用的确非常好非常高效。但它不是没有缺点:

  • I/O多路复用的最大缺点就是编码复杂度。并且多路复用的粒度越低。编码的复杂度越高。

    由于粒度越低说明我们将主流程拆分出很多其它的事件,使得代码流程变得支离破碎。

  • 第二个缺点就是某一事件处理的堵塞或长时间运行将会导致整个流程都无法响应其它事件,由于整个程序都是单线程运行的。比如上面的样例。假设我们花非常多时间读取某个客户端有益发送来的大量数据的话,整个Web服务器将停在那里。
  • 最后一个缺点就是无法充分利用多核处理器的优势,这一缺点也是显而易见的。

原文地址:https://www.cnblogs.com/yfceshi/p/7084572.html