网络实现1:从啥都不知道到服务器并发

写在前面:阅读到这里的你需要有写在总目录里的计算机基础,但无需接触过任何网络相关的概念和程序设计。这篇文章是我的网络系列文章的第1篇,旨在帮助你基于实践而非纯粹的理论,从无到有入门基本的网络编程,了解最基本的网络知识,并利用已学知识实现一个简单的服务器。接下来的内容将尝试回答:
如何通过编程实现网络通信?
什么是服务器-客户端架构?如何实现?
具体结构如下:
网络编程最核心的系统调用socket(),以两种不同的方法使用它和其它计算机通信,顺便引出最最基本的网络知识。
客户端和服务器各自的实现。如何从简单的服务器开始,逐渐优化,让其最终达到工业级服务器的性能。
下面正式开始。
***********************************我是分割线***********************************
网络的整体架构和行为是很直观且易于理解的:众多通信设备被相互连接以交换信息。计算机通过连接这些设备中的某一台来接入由这些通信设备共同构成的“网络”。计算机将数据发送给与其直接相连的通信设备,这台通信设备遵循某种算法通过上述网络将信息送到与目标计算机连接的通信设备,从而实现与该计算机通信。
位于上图中间被涂成蓝色的部分,就是上面说的相互连接用于交换信息的通信设备,被称为核心网;而连接它们从而接入网络的设备被称为边缘网。它们都是计算机设备。数据在真实的物理世界中如何传递不是计算机学科而是通信学科关注的问题,而计算机网络更加关注如何设计操作发送物理信号设备的软件,并继续基于其上实现一系列功能。为了将这个过程中的不同任务隔离,提高执行不同任务模块的性能,网络分层模型在工程实践中逐渐诞生。它既是工程师对于现有网络技术的总结,也是继续优化网络架构时的参考。
上图右边的TCP/IP模型在工程实践中自然诞生,而左边OSI模型是一种人为的,刻意的划分方式。OSI模型实践性不强,更学术而非工程,所以后面将主要基于TCP/IP五层模型讨论。
这里需要给第一次学习网络,但是对网络已有所了解的人说明:我的讨论顺序既不是自底向上,也不是严格的自顶向下。自底向上对工程实践不太友好,思考方式也更接近历史上发明网络的过程,而非如今的工程实现。可如果严格地自顶向下讨论,则需要涉及大量“当前看来很抽象却意义不大”的细节。事实上,我将尝试采用一种“从用户到设计者”的视角:从最小限度的知识开始,先学使用,实现功能,做出产品;然后才是研究原理。当然,如果你几乎没有了解过任何网络相关的概念,完全看不懂我这段话在说什么也很好(甚至更好)。这段话的本意某种意义上就是为了让接触过一点网络的人清空记忆,重回“一张白纸”的状态。
进入网络,我们首先要回答第一个问题:如何编写能访问网络的程序,并让这个程序通过网络给其它计算机发送信息?操作系统提供的socket()系统调用可以帮助实现上述需求,我们就从这里开始。
1.1 socket的基础使用和最少细节的网络知识
第一个Socket程序
如何使用socket()?想象现实世界中寄出一份纸质信件需要:1. 注明地址和收件人,选择邮寄方式(普件或急件,信件保险等)2. 将信放入邮筒。结束。后面会有邮差负责收取和派发信件而无需我们操心。这隐含了一个很重要的事实,收发双方作为邮局的用户只通过邮筒和邮局交换信件,并不直接参与信件的具体运输。使用socket()发送数据和上述物理世界中真实的信件收发很相似:操作系统(邮局)提供接口socket(邮筒)让用户态程序(收发双方)间接访问网络并相互通信。以下代码按照上述流程实现了发送方的功能,给目标发送了一句“Hello!”。
#include <stdio.h>
#include <string.h>
#include <arpa/inet.h> // sockaddr_in, inet_pton
#include <sys/socket.h> // socket(), sendto()
#include <unistd.h> // close()
int main() {
// 创建 IPv4 UDP socket
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
// 设置目标地址
struct sockaddr_in dest_addr;
memset(&dest_addr, 0, sizeof(dest_addr));
dest_addr.sin_family = AF_INET;
dest_addr.sin_port = htons(2000);
const char* target_ip = ""; //可以通过ifconfig命令查看本地ip地址并相应修改
inet_pton(AF_INET, target_ip, &dest_addr.sin_addr);
// 发送消息
const char* message = "Hello!";
ssize_t sent_bytes = sendto(sockfd, message, strlen(message), 0,
(struct sockaddr*)&dest_addr, sizeof(dest_addr));
close(sockfd);
return 0;
}
下图左边展示了刚刚编写的数据发送方代码的流程,右边则是我们接下来会实现的接收方。先关注左边,从上往下,程序首先调用socket(),得到了一个int类型的返回值sockfd(套接字文件描述符socket file description)。socket是类UNIX环境下提供给用户态程序的网络接口,几乎所有的网络通信,都是调用其他网络通信函数操作它展开。而关于它的返回值,其底层是类UNIX环境下的I/O文件描述符。可是我们不是在讲网络吗?和I/O或者文件有什么关系?由于对于文件,I/O,网络等等资源的操作都可以概括为“读”和“写”,UNIX为这些资源都提供了一套行为一致的系统调用和管理方式。在这里暂且先不深究其底层实现,在用户视角的我们只需要知道,它们都被视为“文件”,管理它们的标识符则都被称为文件描述符fd,也就是调用socket()返回的int类型值。我们可以直接通过fd把它们当作文件一样操作。至于向操作系统申请socket的时候需要传递的三个参数留给后面必要时再讲,目前只需把握整体流程即可。如果你希望跟着一起动手,照抄就好。同样,后面所有模糊的地方都是当前不重要但后面会详细解释的知识,也是照抄就好。
接下来一步是告诉操作系统“收件人与地址”。网络虽不像现实世界使用“人可以读懂的具体位置”来标注地址,但仍然是类似“一个地址指明一栋建筑”的结构。你或许此前已经听过,网络中这个指明数据接收方计算机的地址被称为ip地址。此外,由于该计算机上可能同时存在多个socket,向其中哪一个发送数据也需要指定,这个指定号码被称作端口。C语言要求我们把这些信息集中于包含在头文件“arpa/inet.h”的结构体sockaddr_in中。那么上面设置地址的流程就很明确了。首先声明结构体dest_addr并通过memset()初始化为0(同样忽略第三行),调用htosn()将端口号2000标准化(在C中必须通过这个函数设置端口),调用inet_pton()将参数target_ip转化为二进制形式赋值给&dest_addr.sin_addr(同样必须通过这个函数设置ip地址)。至此,端口和ip地址都已经被包含在了结构体dest_addr中。
最后是发送信息。sendto()第四个参数0代表“按照默认行为发送”,其余部分都很好理解就不赘述了。在函数返回前记得调用close(sockfd)回收资源。
尽管因为有很多网络的理论知识目前还未涉及导致上述代码中很多细节只能模糊带过,但整体流程依然很清晰,和我们一开始的比喻几乎一样。下面代码实现了对应的数据接收方,流程类似。
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h> // sockaddr_in, inet_ntop
#include <sys/socket.h> // socket(), bind(), recvfrom()
int main() {
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
// 准备本地地址(接收数据的地址)
struct sockaddr_in recv_addr;
memset(&recv_addr, 0, sizeof(recv_addr));
recv_addr.sin_family = AF_INET;
recv_addr.sin_addr.s_addr = INADDR_ANY; // 监听所有本地IP
recv_addr.sin_port = htons(2000);
bind(sockfd, (struct sockaddr*)&recv_addr, sizeof(recv_addr));
// 接收消息
struct sockaddr_in send_addr;
socklen_t send_len = sizeof(send_addr);
char buffer[1024];
ssize_t recv_len = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0,
(struct sockaddr*)&send_addr, &send_len);
buffer[recv_len] = '\0';
printf("Received message: \"%s\"\n", buffer);
close(sockfd);
return 0;
}
可以发现,除了将调用sendto()替换为调用recvfrom()以外,接收方与发送方流程上的唯一不同是还需要额外调用bind()来将申请到的socket和某个端口绑定。回忆一下发送方代码,这对应了发送方向接收方的指定端口发送信息。如果不绑定,那么接收方的操作系统拿到一条指定了端口的信息时会不知道把它派发给哪一个socket。
至此我们已经成功通过socket实现了一次信息发送!你可以跟着上述流程尝试自己实现,编译,运行。注意,在尝试运行时需要先./recv,再./send。网络数据传递的延迟仅以ms为单位。如果先./send,在还没来得及./recv的时候数据就会送达,并因为没有socket接收而被丢弃。反之,./recv后程序会阻塞在recvfrom()直接收到数据为止。
前面说过,从编程的角度来说,网络通信就是调用各种网络相关的函数操作socket。然而操作socket的函数不止上面这些,因此使用socket的方法也不止这一种。下面将介绍一种与上述方法不同的操作socket的方式,并引出相关的函数。
UDP和TCP传输的使用区别
尽管目前暂时还无法系统介绍网络底层如何传输数据,但有一个事实可以提前给出:网络的底层数据传输不可靠。网络底层架构的固有缺陷导致发出的数据在传输过程中可能会被部分损坏,丢失,或者打乱顺序。尽管概率不高,但上一节实现的代码所发出的数据实际上会面临这种风险!为解决这个问题,一种可靠的数据传输方法在软件层面(而非通过改变底层架构)诞生。尽管它仍然基于网络底层的不可靠传输,但可以保证传输的数据是可靠的。听起来很神奇,但通过socket使用它仍然不复杂。代码如下:
接收方:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
int main() {
int recv_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in recv_addr;
memset(&recv_addr, 0, sizeof(server_addr));
recv_addr.sin_family = AF_INET;
recv_addr.sin_port = htons(2000); // 监听端口
recv_addr.sin_addr.s_addr = INADDR_ANY; // 所有本地IP
bind(recv_fd, (struct sockaddr*)&recv_addr, sizeof(recv_addr));
listen(recv_fd , 1);
struct sockaddr_in send_addr;
socklen_t send_len = sizeof(send_addr);
int conn_fd = accept(recv_fd, (struct sockaddr*)&send_addr, &send_len);
char buffer[1024];
ssize_t conn_len = recv(conn_fd, buffer, sizeof(buffer)-1, 0);
buffer[conn_len] = '\0';
printf("Received: %s\n", buffer);
close(conn_fd);
close(recv_fd);
return 0;
}
发送方:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
int main() {
int sockfd = 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);
const char* recv_ip = ""; //可以通过ifconfig命令查看本地ip地址并相应修改
inet_pton(AF_INET, recv_ip, &recv_addr.sin_addr);
connect(sockfd, (struct sockaddr*)&recv_addr, sizeof(recv_addr));
const char* message = "Hello!";
send(sockfd, message, strlen(message), 0);
close(sockfd);
return 0;
}
新的实现和上一节的实现的第一个区别就是申请socket时,第二个参数由SOCK_DGRAM变成了SOCK_STREAM。它们分别代表了以“UDP”和以“TCP”的方式传输数据。前者是上一节采用的方式,不可靠;后者是这一节采用的方式,可靠。继续向下看,接收方在准备好收发双方的sockaddr_in并调用bind()绑定端口后,调用了一个新的函数listen(recv_fd , 1)。这是因为为实现可靠的数据传输,TCP需要首先在收发双方之间建立连接(之前实现的UDP不用,发了就发了)。而listen()告诉操作系统:开始关注,或者用术语叫做开始监听,有没有其他socket通过网络向recv_fd发起的连接申请。如果有多个连接申请同时发起,最多保留n个,在这里是1个。
接下来,接收方调用accept()。这是也一个新的函数,它阻塞等待直到操作系统通知有连接申请。它会和新的连接申请建立连接,并返回一个新的socket,在这里被记为conn_fd。此后这个连接的管理,包括数据接收全部通过recv_fd(conn_fd, …)来完成。注意,在上一节的UDP传输中,接收方调用recvfrom()返回的直接就是数据;而本节的TCP传输则需要先通过accept()和recv_fd得到conn_fd,再通过conn_fd接收数据。TCP的流程类似于饭店迎宾:迎宾服务员(recv_fd)负责接收新的客人,并把客人交给点餐服务员(conn_fd)。
最后再观察发送方,可以发现整体流程是与接收方对应的。它调用connect()发起连接,阻塞等待直到接收方成功建立连接,然后才调用send()发送数据。
目前我们为止介绍了UDP和TCP两种不同的申请socket()的方式,操作这两种socket()的函数也不一样。读到这里你肯定还感受不到它们两者的区别,也不知道应该在什么情况下使用两者中的哪一种。这些问题随着后面引入新的问题会逐渐得到解答,但在此之前请熟练掌握它们,因为这是一切网络编程的基础,对后续深入理解两种传输方式底层实现的不同也很有帮助。
1.2 服务器——客户端架构和并发服务器
循环顺序执行服务器
通过上一节的学习,我们现在已经可以通过编程的方式访问网络并收发数据,然而它和我们使用网络中逐渐建立起来的直觉之间还有很远的距离。即使了解了上一节的内容,很多网络能够做到的事情看起来仍不可思议。例如:
在浏览器中输入网址,浏览器即可为我展示对应的网页。网址可能以类似前面发送方代码一样的方式发出,但是发给了哪个ip地址?该ip地址上的计算机是怎么做到支持多次访问和并发访问的?
用户在观看视频或收听音乐的过程中滑动时间条,这个动作转换成了什么信息发出?对方接收后又怎么处理?
这篇文章剩余的部分将尝试给出前一个问题的部分答案。
当访问某个网址,浏览器将以某种方式把它转化为确定的ip地址,再用socket()来向这个地址发出消息;这个地址的计算机收到消息后会相对应地回复数据,并包括各种保存在该地址本地的资源,例如文本,图片,音视频等等。用具体的例子说明的话,访问网址www.google.com/intl/en_us/search/howsearchworks,浏览器会得到基于“www.google.com”的ip地址,再用后面的的部分“intl/……”生成控制信息发送到该ip地址。该ip地址给浏览器返回资源,浏览器用这些资源渲染出使用者看得到的网页。
这个语境中请求资源的一方被称为客户端,提供资源的一方被称为服务器。在这种架构下,服务器24/7不间断运行,通过网络处理来自多个客户端的多次请求。观察一下我们上一节的代码并和刚刚的描述对比,能感觉到“发送方”代码整体已经几乎是一个客户端的请求发送模块了,但”接收方“相较于服务器似乎还有所欠缺。其中最大的问题是,”接收方“接收一次请求后便会停止运行,无法支持客户端多次访问,更无法支持多个客户端并发访问。所以接下来,我们尝试基于“TCP接收方”的代码优化,实现一个更“高效,健壮”的服务器。
服务器至少需要支持客户端多次访问。很自然会想到,这一点可以通过修改listen()的参数扩容监听队列,并循环处理连接请求来实现。代码如下:
#include <sys/socket.h>
#include <netinet/in.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <unistd.h> // for close()
#include <arpa/inet.h> // for inet_ntop()
#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);
while (1) {
struct sockaddr_in client_addr;
socklen_t clientlen = sizeof(client_addr);
int connfd = accept(recvfd, (struct sockaddr*)&client_addr, &clientlen);
char buffer[BUF_SIZE];
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 {
printf("recv failed or client closed connection\n");
// 错误处理
}
close(connfd);
printf("Connection closed\n");
}
}
服务器首先创建recvfd,绑定地址,开始监听连接,之后进入主循环。在循环内,accept()阻塞等待。每当接收到新的连接请求,创建connfd,创建缓冲区buffer并接受数据,并对返回值进行判断。recv()返回接收到的字节数。如果大于0意味着成功接收,则在缓冲区buffer尾部补‘\0’后打印。如果等于0表明客户端申请断开TCP连接,小于0则表明发生错误,需要相应处理。最后在进入下一个循环前close()释放资源。
我们目前学到的东西已经可以做很多事情了!例如,客户端向服务器发送文件名,服务器返回对应的文件;或者客户端发送本地文件给服务器,请求使用服务器一端更强大的计算资源,服务器处理文件后将结果返回等。你可以试一试实现需求如下的客户端和服务器:
服务器:执行时接收一个数字参数,记为密码pwd。
协商阶段。启动后,将一个udp socket随机绑定到端口号p1,称之为协商socket,并将端口p1输出,等待接收客户端数据。如果接收到格式严格为”d% d%”(数字+空格+数字)的字符串,将第一个数字解析为密码与pwd对比,相同则进入连接阶段。否则向客户端发送字符”0“(注意不是空字符”\0“)。
连接阶段。将第二个数字解析为端口号p2,监听来自相同客户端上p2端口的TCP连接。连接成功建立后,接收客户端发送来的文件,统计字节数并回发给客户端。至此连接结束,回到协商阶段等待下一次协商。
客户端:执行时接收两个数字参数,第一个记为密码pwd,第二个为服务器启动时打印的端口号p1(这是在模拟已知服务器端口)。
协商阶段。使用udp socket向服务器上p1端口发送格式严格为”d% d%”(数字+空格+数字)的字符串,意义同上,等待服务器回发。如果回发为”0“则意味密码错误,程序结束,如果大于0则记为端口号p2,进入连接阶段。
连接阶段。向端口号p2发起tcp连接,连接成功并发送文件后等待服务器统计文件长度并回发数据。接收到结果后将字节数输出,程序结束。
实现上述客户端服务器所需要的socket编程知识基本上都已经介绍了,但是还需要对很多情况进行错误处理,以此保证服务器即使遇到错误,也能成功恢复并回到协商阶段等待下一次协商,这是服务器24/7运行的基础。
试试看!
select()和poll()
服务器所要面对的挑战肯定不止“支持多次访问”这么简单。一般情况下并发才是更难解决的问题。不难想到可以通过“一请求一线程”的方式,即每建立一个新的连接就为其开一个新的线程来实现并发。这种架构直观且思维难度小,但在工程上却难以实现,甚至无需用代码展示。工业级服务器面对的并发数量通常是数万,数十万,甚至上百万。采用上述方法意味着线程数量会随连接数量线性增加到百万级,带来昂贵的上下文切换开销和巨大的内存压力。
回想一下真实的网络场景,单个socket不间断持续收发数据是少数情况。socket更多时间都在阻塞等待接收数据。那么,我们能否将阻塞等待单个socket的模式更改为,忽略还在等待数据的socket,找出所有有数据到达的socket并一起处理呢?当然可以!事实上,我们不但可以这样处理数据到达,还可以处理可写就绪或异常发生。这恰恰就是系统调用select()和poll()的原理。在select()和poll()的语境下,“有数据可以被读取”被称之为“读事件”。类似的还有“socket准备好发出数据”的“写事件”和“socket出错”的“异常事件”。事件这个术语很自然地在我们当前的语境下诞生,从这里开始我们将频繁使用这个词。
在介绍两个函数具体的使用细节前,我们先讨论一下它们的行为。如上所述,用户态程序只关心有事件发生的fd,而操作系统提供了为用户态程序监听这些fd的便利。用户态程序可以将所有的fd全部装进一个集合里,把这个集合交给操作系统使其帮助监听集合内的fd是否有事件发生。操作系统检查集合内的所有fd,将每个fd标记为1或0,代表“有事件”或者“无事件”,再将整个集合还给用户态程序,这样用户态程序只需逐个检查fd并处理事件即可。
在具体实现中,这个fd集合通常是一个bit数组或类似的结构,也就是说操作系统如果想要检查整个集合,每次都需要遍历整个数组。为了避免这一点以提高效率,除了集合以外,用户态程序还需要传递一个maxfd以限定最大检查下标。
select()和poll()的行为都如上所述,只是具体如何管理集合有所不同。我们先讲select():
#include <sys/socket.h>
#include <netinet/in.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <unistd.h> // for close()
#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);
fd_set rfds, rset;
FD_ZERO(&rfds);
FD_SET(recvfd, &rfds);
int maxfd = recvfd + 1;
while(1){
rset = rfds;
select(maxfd, &rset, NULL, NULL, NULL);
if (FD_ISSET(recvfd, &rset)){
int clientfd = accept(recvfd, (struct sockaddr*)&client_addr, &clientlen);
FD_SET(clientfd, &rfds);
if (clientfd >= maxfd) maxfd = clientfd + 1;
}
for (int i = 0; i < maxfd; ++i){
if (i == recvfd) continue;
if (FD_ISSET(i, &rset)){
char buffer[BUF_SIZE] = {0};
ssize_t n = recv(i, 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(i, reply, strlen(reply), 0);
} else if (n == 0) {
close(i);
FD_CLR(i, &rfds);
continue;
}
}
}
}
}
从上往下看,第一个新的类型是fd_set,前面提到具体实现差不多是一个bit数组的集合就是它。使用select()需要定义三个fd_set:readfds,writefds,exceptfds。操作系统只关心每个集合名中的事件。例如,只关心readfds集合中的fd是否有写事件发生,依此类推。我们目前的服务器实现只需要关注能否recv(),即写事件,故在此只声明了rfds用于管理所有fd,和作为readfds的rset。继续向下,FD_ZERO()初始化rfds,FD_SET()将recvfd加入rfds,定义最大检查下标maxfd(由于select()的检查方式类似“i<maxfd”,故需要+1)。
进入循环体,将rfds赋值给rset,调用select(),五个参数依次为maxfd,读事件readfds,写事件writefds,异常事件exceptfds,和timeout。在这里无需写/异常事件集合,传空指针NULL;期望阻塞等待,故timeout也为NULL。调用select()后,操作系统监听三个集合,当有任一事件触发就修改三个集合(在这里是一个集合)并返回发生事件的fd总数量(在这里没有用变量接)。接下来用户态程序调用FD_ISSET()逐个检查fd_set中的fd,对recvfd的读事件建立新的连接并将新连接加入rfds,对其他fd的读事件接收数据并在需要的时候调用FD_CLR()清除已经关闭的fd。
select()讲完了。从使用体验的角度来说它需要的参数有点太多了。poll()的行为在用户态程序的视角下和select()的行为基本是一样的,但是大大简化了调用所需的参数:
#include <sys/socket.h>
#include <netinet/in.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <unistd.h> // for close()
#include <poll.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);
struct pollfd fds[BUF_SIZE] = {0};
fds[recvfd].fd = recvfd;
fds[recvfd].events = POLLIN;
int maxfd = recvfd + 1;
while(1){
poll(fds, maxfd, -1);
if (fds[recvfd].revents & POLLIN){
int clientfd = accept(recvfd, (struct sockaddr*)&client_addr, &clientlen);
fds[clientfd].fd = clientfd;
fds[clientfd].events = POLLIN;
if (clientfd >= maxfd) maxfd = clientfd + 1;
}
for (int i = 0; i < maxfd; ++i){
if (i == recvfd) continue;
if (fds[i].revents & POLLIN){
char buffer[BUF_SIZE] = {0};
ssize_t n = recv(i, 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(i, reply, strlen(reply), 0);
} else if (n == 0) {
close(i);
fds[i].fd = -1;
fds[i].events = 0;
continue;
}
}
}
}
}
如果已经理顺了select()的行为,你会发现poll()基本没啥要讲的。它和select()唯一的不同就是管理fd的方式不一样。select()要求将fd装入三个关注不同的事件的不同集合。而poll()直接要求为每个fd都定义一个结构体pollfd:
struct pollfd {
int fd;
short events;
short revents;
};
三个filed分别代表fd,关注什么事件,实际返回了什么事件。events和revents都通过宏定义使用。注意到判断事件的代码:
// .......
if (fds[recvfd].revents & POLLIN){
// ......
}
这里使用了位运算&。events,revents,POLLIN等实际上都是二进制数,被定义为0,1,2,4,8这些转换成二进制后只有某个bit为1其余都为0的值。这样直接&运算并判断结果是否为全0要比==运算快得多。
最后,关于select()和poll()的使用场景。现如今除了比较老的版本,基本都不使用select()而改为使用poll()了。
1.3 总结
本章我们从socket编程开始,一步一步搭建起了一个基础的服务器,实现了:
UDP的收发双方
TCP的收发双方
基于TCP的循环服务器
基于select()/poll()的并发服务器
但是它距离“百万级”的并发似乎仍然遥远。如何系统地管理数量众多的连接fd?每个循环都需要调用select()/poll()检查所有的fd,但是其中绝大多数都无事件。这其中似乎还有很大的效率提升空间。此外读者或许也会疑惑,为什么开头的层级模型在实现的过程中似乎完全没有提到?我们甚至不知道身处哪一层级。正如之前所说,我们走在一条“从产品到原理”的路上。目前我们淹没在服务器实现的问题中,而问题解决后会很自然地向其他层级过渡。这也正是下一章将要讨论的内容。
Subscribe to my newsletter
Read articles from Charizard directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
