网络实现2:从并发到应用层服务器

CharizardCharizard
5 min read

写在前面:阅读到这里的你要能够使用socket()实现普通的非并发服务器,使用socket()和select()/poll()实现并发服务器,并大致明白这两种实现各自有什么缺陷。这篇文章是我的网络系列文章的第2篇,旨在帮助你基于实践而非纯粹的理论,从select()/poll()并发服务器过渡到epoll()并发服务器从而大大扩充并发数量,并引入新的编程范式和工程管理手段来高效管理新增的大量并发连接。接下来的内容将尝试回答:

  1. epoll()的行为是怎样?有什么优缺点?为什么它能极大扩充并发量?

  2. 当并发规模显著增加,用什么手段可以高效管理?

  3. 使用上述技术可以解决什么问题?如何实现上一节提到的网页访问?

具体结构如下:

  1. 简单介绍epoll()的原理从而说明为什么它能够实现大并发。epoll()的使用方法。

  2. 通过epoll()引出事件驱动编程。引入连接队列管理随并发量大量增加的连接。

  3. 介绍应用层的理论知识,并综合1&2的技术手段实现一个简单的http server。

下面正式开始。

***********************************我是分割线***********************************

回顾一下上一章结尾的问题。我们基于select()/poll()实现了服务器并发,它们都比一请求一线程的架构更高效。但是,每次调用select()和poll(),操作系统都会把fd集合从用户态拷贝到内核态并全部遍历,这种行为在大并发场景下也仍谈不上是效率最高的做法。目前为止,上一章引入的大并发场景下的效率问题仍是首要问题。面对同样的问题,Linux从2.5.44起提供了一个新的可扩展I/O事件通知机制epoll,让需要大量操作fd的程序得以发挥更优异的性能。Linux在该版本发布后逐渐成为服务器开发的绝对主流与epoll关系密切。本章就从这个函数开始。

2.1 epoll

与上一章介绍select()/poll()一样,还是从函数的行为开始说起。沿用我们之前邮差收信的比喻。有的邮筒存放了信件,有的邮筒什么都没有。邮差不知道每个邮筒的情况,需要一个一个邮筒地检查。这是poll()。现在为了提升邮差工作的效率,小区物业负责信件收集工作。它们派人把信件收集到门房,这样邮差可以直接在小区门口统一拿走所有信件。这是epoll()。下面是一个简单的示意图。

经过上述优化后,操作系统不再返回没有伴随事件的I/O。通过epoll()拿到的fd集合中的fd都是有事件的fd,用户态程序无需再挨个检查。此外,用户态程序调用epoll()时fd集合也无需再从用户态拷贝到内核态。这是因为在epoll的机制下,fd集合一直由内核态负责管理。用户态程序调用epoll()时操作系统直接返回事件集合,从而避免了拷贝。

有的读者可能会疑惑:听起来好像没什么特别的,只是用户态程序无需再检查一遍fd集合而已,对效率会有很大提升吗?epoll()面对大数量活跃I/O时,即使和select()相比实际效率也谈不上能有质的提升。它的独特优势体现在面对大数量I/O但其中仅有少部分活跃的场景。这个问题会在后面实现epoll()的时候有详细解释。请记住我们目前只是系统调用的“用户”,而非“开发者”。

下面是epoll版本的服务器实现:

#include <sys/socket.h>
#include <netinet/in.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>     // for close()
#include <sys/epoll.h>
#define BUF_SIZE 1024

int main(){
    int recvfd = socket(AF_INET, SOCK_STREAM, 0);

    struct sockaddr_in recv_addr;
    memset(&recv_addr, 0, sizeof(recv_addr));
    recv_addr.sin_family = AF_INET;
    recv_addr.sin_port = htons(2000);
    recv_addr.sin_addr.s_addr = INADDR_ANY;
    bind(recvfd, (struct sockaddr*)&recv_addr, sizeof(recv_addr));
    listen(recvfd, 10);

    struct sockaddr_in client_addr;
    socklen_t clientlen = sizeof(client_addr);



    int epfd = epoll_create(1);

    struct epoll_event recv_ev;
    recv_ev.events = EPOLLIN;
    recv_ev.data.fd = recvfd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, recvfd, &recv_ev);

    while(1){
        struct epoll_event events[BUF_SIZE] = {0};
        int nready = epoll_wait(epfd, events, 1024, -1);

        for (int i = 0; i < nready; ++i){
            int connfd = events[i].data.fd;

            if (connfd == recvfd){
                int clientfd = accept(recvfd, (struct sockaddr*)&client_addr, &clientlen);
                struct epoll_event client_ev;
                client_ev.events = EPOLLIN;
                client_ev.data.fd = clientfd;
                epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &client_ev);
            } else if (events[i].events & EPOLLIN){
                char buffer[BUF_SIZE] = {0};
                ssize_t n = recv(connfd, buffer, BUF_SIZE-1, 0);
                if (n > 0){
                    buffer[n] = '\0';
                    printf("Received from client: %s\n", buffer);
                    const char *reply = "Message received\n";
                    send(connfd, reply, strlen(reply), 0);
                } else if (n == 0) {
                    close(connfd);
                    epoll_ctl(epfd, EPOLL_CTL_DEL, connfd, NULL);
                    continue;
                }
            }
        }
    }
}

整体流程和poll()仍然相似。不同之处在于,第一,要调用epoll_create()创建epoll文件描述符,这相当于创建了”驿站/门房”。调用该函数唯一需要的参数是一个int,它指定了监听了fd的总数量。然而现如今epoll的底层实现已经不再是老版本的数组而是链表,导致这个参数已经不重要了。它的遗留主要是为了兼顾老版本的代码。

第二点不同,调用epoll_ctl()来管理总集中的fd。它需要四个参数

参数含义
epfd这个参数就是epoll_create函数的返回值(也就是4)
op表示的是具体的动作,如EPOLL_ADD、MOD、DEL
fd表示需要监听的文件描述符
*event具体的事件,如EPOLLIN(读事件)

第1,3个已经介绍过了。第2个参数,控制符为DEL代表从fd集合中删除,故不需要第4个参数。第4个参数和poll中的pollfd类似。只不过现在传入和返回的事件都是ev.events,而不再分成events和revents两个field。

第三点不同,调用epoll_wait()得到事件fd。

参数含义
epfd这个参数就是epoll_create函数的返回值(也就是4)
*event返回的事件会存储在这个事件列表里
maxevents最大文件描述符
timeout-1代表阻塞等待

后面for+if/else处理的events列表里全部都是伴随事件的fd。

2.2 面向事件编程和连接队列

讲到这里,面对“事件”这个新的概念,不妨停下来想一想,如果我们要扩展更多的事件,例如写事件,异常等等,该怎么做呢?基于目前的代码结构,我们要把对事件的处理代码全部放在while主循环体中的if/else结构中。这意味着想要扩展功能就需要修改main函数,继续写更长的if/else结构,而这样组织的代码会难以维护和扩展。此外,发送的数据也不会始终只是“Hello!”这么简单。当前的在临发送前才定义buffer并往里填数据的方法也很明显支持不了支持不了发送超过缓冲区大小的数据,也支持不了连续发送多个文件的场景。我们需要为每个fd都分配一个生命周期超出while循环的缓冲区。

我们先讲前者,如何扩展事件。在之前的编程逻辑中,我们得判断每个fd的值和返回了什么事件,并在if/else控制流中编写代码块。一旦功能开始扩展,if/else将会变得极其复杂,代码也将变得难以阅读。如果用流程图表示,conn下将会延伸出很多,且逻辑复杂的分支。

不妨试试从事件的角度思考。目前每个fd仅有EPOLLIN和EPOLLOUT两种事件需要被处理,但相同的事件由于fd的不同,处理方法也不同。例如,recvfd针对EPOLLIN调用accept(),而connfd针对EPOLLIN调用recv()。对此,我们可以在每个fd创建之初就为它分配处理对应事件的函数,并用一个数据结构管理起来。每当事件触发,我们只需要利用该数据结构,调用fd对应的函数,而无需再关心到底具体是哪个fd+哪种事件的组合。这种方式还隔离了事件具体的处理逻辑和服务器运行的主逻辑,降低了思维难度。

我们先试着考虑一下recvfd会如何处理事件。它被EPOLLIN触发时需要负责接收连接,创建connfd。这些功可以封装在下面的函数中:

int accept_cb(int fd){
    struct sockaddr_in clientaddr;
    socklen_t len = sizeof(clientaddr);
    int clientfd = accept(fd, (struct sockaddr*)&clientaddr, &len);
    return 0;
}

recvfd没有EPOLLOUT事件,无需实现对应的回调函数。而EPOLLIN和EPOLLOUT都有可能触发connfd,所以两者对应的回调函数都需要实现:

int recv_cb(int fd){
    char buffer[BUF_SIZE] = {0};
    ssize_t n = recv(connfd, buffer, BUF_SIZE-1, 0);
    if (count == 0){
        close(fd);
        epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
        return 0;
    }
    recv();
    return count;
}

int send_cb(int fd){
    // prepare for the data to be sent
    int count = send();
    return count;
}

上面的三个函数各自对应了一种fd+一种事件的组合:accept_cb()——recvfd+EPOLLIN,recv_cb()——connfd+EPOLLIN,send_cb()——connfd+EPOLLOUT。这种对应关系仅仅只需要在创建fd的时候进行管理,将fd+event的组合与某一个callback匹配并封装在数据结构中即可。每当后面调用epoll(),拿到操作系统返回的事件列表时,我们只需要将事件列表中的每个fd取出,判断事件并调用对应的函数。

通过上图可以很清晰地看出新的编程范式的逻辑。我们不再需要像之前的范式那样在主循环中写非常复杂的if/else判断,而是只关心事件+调用回调函数。这种新的编程范式因其强调事件event和与事件匹配的函数,因而被称为event-driven programming事件驱动编程,而对应的事件处理函数被称做RCALLBACK回调函数。这种编程范式在如今的程序设计中使用场景非常广泛,甚至在有些场景下没有它几乎寸步难行。

说完了事件驱动编程,我们还需要关注如何管理新封装的函数。相关元素已被包含在上图。最左边的conn_list连接队列即为前面提到过用于管理fd+event组合的数据结构。定义如下:

#define CONNECTION_SIZE 1024
#define BUFFER_SIZE 1024

typedef int (*RCALLBACK)(int fd);

struct Connection{
    int fd;

    RCALLBACK in_callback;
    RCALLBACK out_callback;
};
struct Connection conn[CONNECTION_SIZE] = {0};

它使用类型定义模拟了面向对象的写法,把函数定义在结构体内,从而将fd+事件统一封装。基于它,程序可以直接:

int main(){
    // ......
    conn[recvfd].fd = recvfd;
    conn[recvfd].in_callback = accept_cb;
    // ......
    while (1){
        // ......
        for (){
            // ......
            if (recvfd & EPOLLIN){
                conn[recvfd].in_callback(recvfd);
            } 
            // ......
        }
    }
}

这也就是上面说的,仅需在创建connfd时指定其对应的回调函数。在后面处理事件时我们甚至无需关心它对应的回调函数是哪一个,直接调用即可。

如何扩展事件的问题解决了,现在到了第二个问题:“……此外,发送的数据也不会始终都只是一句“Hello!”这么简单。当前的在临发送前才定义buffer并往里填数据的方法也很明显支持不了支持不了发送超过缓冲区大小的数据,也支持不了连续发送多个文件的场景。我们需要为每个fd都分配一个生命周期超出while循环的缓冲区……”。可以发现这个问题随着连接队列的诞生已经迎刃而解了。我们只需要扩展conn_list,为每个fd加入读写缓冲区即可:

#define CONNECTION_SIZE 1024
#define BUFFER_SIZE 1024

typedef int (*RCALLBACK)(int fd);

struct Connection{
    int fd;

    char rbuffer[BUFFER_SIZE];
    int rlength;

    char wbuffer[BUFFER_SIZE];
    int wlength;

    RCALLBACK in_callback;
    RCALLBACK out_callback;
};
struct Connection conn[CONNECTION_SIZE] = {0};

基于这一版新的连接队列,上面的三个回调函数也要相应更新。下面我会直接放出完整的代码,其中包含了更新后的三个回调函数,和对应之前编程范式里尚未封装的功能(例如启动recvfd开始监听,切换事件等等)所对应的函数。

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/epoll.h>

#define BUFFER_SIZE 1024
#define CONNECTION_SIZE 1024


int epfd;
typedef int (*RCALLBACK)(int fd);

struct Connection{
    int fd;

    char rbuffer[BUFFER_SIZE];
    int rlength;

    char wbuffer[BUFFER_SIZE];
    int wlength;

    RCALLBACK out_callback;
    RCALLBACK in_callback;
};
struct Connection conn[CONNECTION_SIZE] = {0};


void set_event(int fd, int event, int flag){
    struct epoll_event ev;
    ev.events = event;
    ev.data.fd = fd;
    if (flag){
        epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
    } else {
        epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev);
    }
}


int recv_cb(int fd){
    memset(conn[fd].rbuffer, 0, BUFFER_SIZE);
    int count = recv(fd, conn[fd].rbuffer, BUFFER_SIZE, 0);
    if (count == 0){
        close(fd);
        epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
        return 0;
    }
    conn[fd].rlength = count;
    printf("Received from client: %s\n", conn[fd].rbuffer);
    set_event(fd, EPOLLOUT, 0);
    return count;
}


int send_cb(int fd){
    const char *reply = "Message received\n";
    conn[fd].wlength = strlen(reply);
    strncpy(conn[fd].wbuffer, reply, conn[fd].wlength-1);
    conn[fd].wlength = '\0';
    int count = send(fd, conn[fd].wbuffer, conn[fd].wlength, 0);
    set_event(fd, EPOLLIN, 0);
    return count;
}


int accept_cb(int fd){
    struct sockaddr_in clientaddr;
    socklen_t len = sizeof(clientaddr);
    int clientfd = accept(fd, (struct sockaddr*)&clientaddr, &len);

    conn[clientfd].fd = clientfd;
    conn[clientfd].out_callback = send_cb;
    conn[clientfd].in_callback = recv_cb;
    memset(conn[clientfd].rbuffer, 0, BUFFER_SIZE);
    conn[clientfd].rlength = 0;
    memset(conn[clientfd].wbuffer, 0, BUFFER_SIZE);
    conn[clientfd].wlength = 0; 

    set_event(clientfd, EPOLLIN, 1);

    return 0;
}


int init_server(unsigned short port){
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);

    struct sockaddr_in servaddr;
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons (port);
    bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr));
    listen(sockfd, 10);
    return sockfd;
}


int main(){
    unsigned short port = 2000;
    epfd = epoll_create(1);
    int sockfd = init_server(port);
    conn[sockfd].fd = sockfd;
    conn[sockfd].in_callback = accept_cb;
    set_event(sockfd, EPOLLIN, 1);

    while (1){
        struct epoll_event events[1024] = {0};
        int n_ready = epoll_wait(epfd, events, 1024, -1);
        for (int i = 0; i < n_ready; ++i){
            int connfd = events[i].data.fd;
            if (events[i].events & EPOLLIN){
                conn[connfd].in_callback(connfd);
            } 
            if (events[i].events & EPOLLOUT){
                conn[connfd].out_callback(connfd);
            }
        }
    }
}

总结一下。旧的实现fd的行为由控制流决定,而在新的实现中fd的行为由事件决定:针对不同的事件,调用不同的回调函数。如果需要处理更多的事件,只需要定义新的cb函数,调整有关系的回调函数即可。main函数,也即上面流程图中的主逻辑在这种实现下是确定的。

至此,我们基本讲完了如何组织大并发场景下的socket编程。现如今的服务器底层实现基本都是事件驱动+连接队列+epoll/poll,各种功能也都是基于这个架构扩展功能得到的。既然已经提到,那么本章的最后一节就和上一章用socket()实现服务器一样,用本章前面几节介绍的这个框架来实现一个HTTP服务器。通过它能够很具象地展示为什么这个架构非常适配应用层服务的开发,以及上一章还没具体介绍的输入网址得到网页的过程。

2.3 HTTP服务器

在继续工程问题的讨论之前,在这里有必要,也终于有机会介绍一点网络分层模型的内容。还记得在第一章开头介绍网络和socket时,我们曾将城市比作计算机,邮局比作操作系统,邮筒比作socket。从那里开始一直到这一节开始前,我们始终以信件收发双方的视角在编写代码。处于这个位置的程序是应用层程序,收发的内容(在我们前面编写的代码里仅仅只是一句”Hello!”)是应用层数据。从职责的角度讲,应用层负责收发数据,组织数据的内容从而实现功能。从编程的角度不严谨地讲,只有位于应用层的程序使用socket,其它层级都位于其下,给socket提供功能。前面我们多把注意力放在如何以更好地收发数据上,而鲜有关心数据的具体内容应该如何组织和解析,这是因为前者确实是C/C++网络编程的重点。不过后者仍然是重要的!接下来你将会看到,联网应用程序的功能很依赖数据如何组织和解析。因此现在得谈谈信件上的内容,也即数据本身。

如果用中文给一个美国人写信,那么对方肯定看不懂,除非对方懂中文。总而言之,信件得用对方可以理解的方式写。人类有语言作为交谈双方都可以理解的沟通规范,计算机相互通信自然也需要类似的规范。例如,如果通过socket请求文件,客户端发送的语句必须能让服务器知道是什么文件。这种在计算机网络通信中,用于规范双方发送数据的标准被称之为协议protocol。网络的每一层级都有独属于该层的协议,不同层级的协议都服务于所属层级的功能,也因而有不同的组织和解析方式,表达的不同的含义。应用层因应用程序种类繁多,需求各异而诞生了很多不同的协议。事实上,你完全可以为你开发的应用程序设计原创的协议而不必遵守任何规范,只要你的客户端和服务器能基于这款协议实现你期望的功能即可。有很多耳熟能详的应用正是这么做的,例如Discord。说到底,应用层协议只是“信的具体内容到底写什么”,只需要对方能看懂就行了。

当然也存在反例。如果某一类应用会被市面上多个不同厂商共同实现,那就需要一套大家都承认的协议,例如网页浏览器和HTTP协议。下面先放两段HTTP协议的示例:

GET / HTTP/1.1
Host: www.google.com
HTTP/1.1 200 OK
Content-Length: 3059
Server: GWS/2.0
Date: Sat, 11 Jan 2003 02:44:04 GMT
Content-Type: text/html
Cache-control: private
Set-Cookie: PREF=ID=73d4aef52e57bae9:TM=1042253044:LM=1042253044:S=SMCc_HRPCQiqy
X9j; expires=Sun, 17-Jan-2038 19:14:07 GMT; path=/; domain=.google.com
Connection: keep-alive

// HTML

前者由客户端发送给服务器,称为请求request;后者由服务器接收到前者后随其他资源一并回复给客户端,称为响应response。HTTP协议是web,或者说是通过浏览器访问网页的基础。其设计之初,是为了提供一种发布和接收HTML页面——你可以理解为网页——的方法。上述的请求和响应都要遵守HTTP协议。但在这里需要强调,协议仅仅用于与其他主机上的相同层级通信,对于其下的层级不会造成任何影响,就好像无论信件里写什么都不是邮局罢工的理由一样。上层的信息对于更低的层级来说不具备任何“语义”上的意义,都只是需要传输的数据罢了。例如当客户端组织了一段不符合上述规则的请求,socket在接收时仍然不会产生任何错误,直到服务器接收到发现无法解析时才会将其识别为错误的HTTP协议,并回复能告知客户端“请求错误”的响应。但这对于底层的socket来说仍然不是错误。

关于HTTP协议如何组成,代表什么含义等等协议本身的内容这里都不会详细说明。应用层协议具体如何组织说到底还是服务于应用程序功能的实现,这的确不是C/C++网络编程的重点。我们在这里简单提及HTTP协议,最主要的原因是为了引入协议这个在其它层级也很重要的概念,并顺便介绍一下层级模型的应用层。接下来我们将展示如何将前面实现的epoll并发服务器扩展为一个收发HTTP协议的服务器,也就是一个HTTP Server。你会发现实现HTTP Server会将数据收发的部分,和HTTP协议的功能部分相互隔离,分别封装在不同的模块中。这意味着你只要掌握了底层的数据收发(也就是从上一章开始一直讲到现在的服务器并发),你可以基于它实现任何你感兴趣的应用层协议。你只需要查阅你感兴趣协议的文档,实现模块的功能,最后导入即可。

现在回到epoll服务器。要在此之上实现一个HTTP Server,仅需将其收发的数据从展示用的“Hello!”更改为HTTP协议即可。那么,recv_cb()和send_cb()都要相应地更改。其不再直接调用recv()或send(),而是调用处理HTTP协议的函数,如下:

int recv_cb(int fd){
    memset(conn[fd].rbuffer, 0, BUFFER_SIZE);
    int count = recv(fd, conn[fd].rbuffer, BUFFER_SIZE, 0);
    if (count == 0){
        close(fd);
        epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
        return 0;
    }
    conn[fd].rlength = count;
    http_request(&conn[fd]);
    set_event(fd, EPOLLOUT, 0);
    return count;
}

int send_cb(int fd){
    http_response(&conn[fd]);
    int count = send(fd, conn[fd].wbuffer, conn[fd].wlength, 0);
    set_event(fd, EPOLLIN, 0);
    return count;
}

在这个函数逻辑下,http_request()负责处理收取到的请求,http_response()负责组织响应并填入缓冲区。实现如下:

#include <stdio.h>
#include "http.h"

int http_request(struct Connection *c){
    printf("request: %s\n", c->rbuffer);
}

int http_response(struct Connection *c){
    c->wlength = sprintf(c->wbuffer,
        "HTTP/1.1 200 OK\r\n"
        "Content-Type: text/html; charset=UTF-8\r\n"
        "Content-Length: 59\r\n"
        "hello: world\r\n"
        "\r\n"
        "<!DOCTYPE html><html><body><p>Hello, world!</p></body></html>\r\n"
    );

    return c->wlength;
}

在这里我们只将请求打印,并组织确定的响应。在实际的HTTP Server中,在收到请求和返回响应之间还涉及组织SQL语句,查找资源等环节。

最后还要组织如下头文件:

#ifndef _HTTP_H_
#define _HTTP_H_

#define BUFFER_SIZE 1024
typedef int (*RCALLBACK)(int fd);
struct Connection{
    int fd;

    int status;

    char rbuffer[BUFFER_SIZE];
    int rlength;

    char wbuffer[BUFFER_SIZE];
    int wlength;

    RCALLBACK out_callback;
    RCALLBACK in_callback;

};

int http_request(struct Connection *c);
int http_response(struct Connection *c);


#endif

现在让我们尝试一下编译,运行,并用浏览器访问我们自己实现的服务器。启动服务器后在浏览器键入你运行该服务器的ip地址和端口2000,你将能看到下面的内容:

现在让我们最后考虑一种情况。浏览器渲染网页的资源通常包括多种不同的文件:文本,图片,音视频等等……这多个文件的回发仅仅对应对该网页的一次请求。在上面的实现中,clientfd的关注事件在调用一次send_cb()后会被设置为EPOLLIN,但多个文件的回发意味着I/O需要连续处理多次EPOLLOUT事件,调用多次send_cb()。目前的代码结构无法实现这种控制,为解决这个问题,我们继续扩展连接队列,引入一个状态量。

struct Connection{
    int fd;

    int status;

    char rbuffer[BUFFER_SIZE];
    int rlength;

    char wbuffer[BUFFER_SIZE];
    int wlength;

    RCALLBACK out_callback;
    RCALLBACK in_callback;
};

例如,让status为0代表发送完毕,为1代表未完成。在这种设置下,多次回发的控制仅需在http_response()和send_cb()中对该参数进行判断即可实现(这实际上实现了一个有限状态机)。当然,这个量代表几个状态,分别用什么数字代表,具体如何处理逻辑……这些也是应用层协议如何设计的问题,在这里就不赘述了。

2.4 总结

本节引入了连接队列Connection_List + 面向事件编程 Event-Based Programming来管理单个程序中的socket()。我们从引入epoll开始,先介绍使用方法,再引出事件驱动编程,引出连接队列并将其逐步扩展,并最终实现了一个简单的HTTP服务器。本章介绍的架构在如今的服务器底层中非常常见,请务必理解透彻并上手实践。

现在我们已经掌握了socket的基本使用,对应用层有了一个简单的了解。然而,有不少的疑惑还伴随着这些学习成果。例如,刻意被忽略的socket()的参数, TCP到底是怎么在不可靠的传输之上实现可靠的传输的?从下一节开始,我们将离开应用层,进入网络层级模型的核心,研究传输层和网络层的原理,回答我们在过去两章中积累的疑惑。

0
Subscribe to my newsletter

Read articles from Charizard directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Charizard
Charizard