之前在内部分享过一次关于NIO相关的知识,感觉通过这次整理,对NIO和Epoll整体上又多了一些认识,虽然没有能力阅读内核源码,但是希望这篇文章可以从整体上帮助各位认识NIO和Epoll。
计算机执行程序时,会有优先级的需求。比如,当计算机收到断电信号时(电容可以保存少许电量,供CPU运行很短的一小段时间),它应立即去保存数据,保存数据的程序具有较高的优先级。
一般而言,由硬件产生的信号需要cpu立马做出回应(不然数据可能就丢失),所以它的优先级很高。cpu理应中断掉正在执行的程序,去做出响应;当cpu完成对硬件的响应后,再重新执行用户程序。中断的过程如下图,和函数调用差不多。只不过函数调用是事先定好位置,而中断的位置由“信号”决定。
不同的设备对应的中断不同,而每个中断都通过一个唯一的数字标识。这些中断值通常被称为中断请求(IRQ)线。比如,IRQ0是时钟中断,而IRQ1是键盘中断。并不是所有的中断号都这样严格定义,像PCI总线上的设备,中断就是动态分配的。
中断号在中断处理过程中起到很重要的作用,在采用向量中断方式的中断系统中,CPU必须通过它才可以找到中断服务程序的入口地址,实现程序的转移。为了在中断向量表中查找中断服务程序的入口地址,可由中断号(n)×4得到一个指针,指向中断向量(即中断服务程序的入口地址)存放在中断向量表的位置,从中取出这个地址(CS:IP),装入代码段寄存器CS和指令指针寄存器IP,即转移到了中断服务程序。
当网卡把数据写入到内存后,网卡向cpu发出一个中断信号,操作系统便能得知有新数据到来,再通过网卡中断程序去处理数据(也就是数据经过DMA已经从磁盘缓冲区到内核了,cpu介入内核态将数据从内核返回到用户进程内存,如果没有DMA的话,可能要CPU多次从磁盘缓冲区拷贝到内核态,然后再从内核态到用户进程内存)。
中断函数是 驱动程序注册到Linux Kernel中的中断子系统注册的中断处理函数。
数据的发送与接收:
当我们需要发送数据时,最终调用的是网卡驱动提供的函数:net_device->hard_start_xmit();
当我们接收到数据时,会触发中断,中断处理函数调用会调用内核函数来接收数据(放入缓冲区?),最终由驱动程序调用内核函数netif_receive_skb(),把报文送入协议栈(接下来的代码硬件无关,与具体报文处理协议相关,比如:ARP协议,IPv4协议,IPv6协议等)。
网卡的中断处理函数在调用内核函数接收数据时又分为非NAPI/NAPI两种方式;
NAPI方式涉及到中断的下半部处理的概念以及软中断。
报文通过**netif_receive_skb()**送入协议栈之后,首先判断需不需要进行桥接处理;
如果报文没有被桥接代码处理,再调用协议处理函数来处理;
中断合并:当数据量很少的时候,每来一个数据包网卡都回产生一个中断,kernel响应这个中断,从网卡缓冲区中读出数据放进协议栈处理,当满足一定条件时,kernel回调用户代码,这里的“回调”一般情况下是指从一个kernel syscall中返回(在此之前用户代码一直处于block状态)。
当数据量很大时,每个包都产生一个中断就划不来了,此时kernel可以启动interrupt coalescing
机制,让网卡做中断合并
,也就是说来足够多的数据包或者等待一个timeout才会产生一个中断,kernel在响应中断时会把所有数据一起读出来处理,这样可以有效的降低中断次数。
当数据量更大时,网卡缓冲区里几乎总是有未处理的数据,此时kernel干脆会禁掉网卡的中断,切换到轮询处理的模式,说白了就是跑一个忙循环不停地读网卡缓冲区里的数据,这样综合开销更低。
系统调用就是软中断,也就是用户态切换到内核态
所有软中断的中断号都是0x80,它是上层应用程序与Linux系统内核进行交互通信的唯一接口。其中断处理程序是system_call
,当检测到系统调用发生时(int 0x80中断),第一步先保存现场,通过一个宏指令SAVE_ALL
实现的,这个指令是把寄存器的状态通过压栈的方式保存起来。
然后会调用sys_call_table
,通过eax寄存器
的值查找系统调用表,找到几号系统调用,然后调用相应的系统调用。
最终恢复现场,然后应用继续执行。
工作队列
操作系统为了支持多任务,实现了进程调度的功能,会把进程分为“运行”和“等待”等几种状态。运行状态是进程获得cpu使用权,正在执行代码的状态;等待状态是阻塞状态,比如上述程序运行到recv时,程序会从运行状态变为等待状态,接收到数据后又变回运行状态。操作系统会分时执行各个运行状态的进程,由于速度很快,看上去就像是同时执行多个任务。
下图中的计算机中运行着A、B、C三个进程,其中进程A执行着上述基础网络程序,一开始,这3个进程都被操作系统的工作队列所引用,处于运行状态,会分时执行。
等待队列
当进程A执行到创建socket的语句时,操作系统会创建一个由文件系统管理的socket对象(如下图)。这个socket对象包含了发送缓冲区、接收缓冲区、等待队列等成员。等待队列是个非常重要的结构,它指向所有需要等待该socket事件的进程。
创建socket
当程序执行到recv时,操作系统会将进程A从工作队列移动到该socket的等待队列中。由于工作队列只剩下了进程B和C,依据进程调度,cpu会轮流执行这两个进程的程序,不会执行进程A的程序。所以进程A被阻塞,不会往下执行代码,也不会占用cpu资源。
ps:操作系统添加等待队列只是添加了对这个“等待中”进程的
引用
,以便在接收到数据时获取进程对象、将其唤醒,而非直接将进程管理纳入自己之下
。上图为了方便说明,直接将进程挂到等待队列之下。
唤醒进程
当socket接收到数据后,操作系统将该socket等待队列上的进程重新放回到工作队列,该进程变成运行状态,继续执行代码。也由于socket的接收缓冲区已经有了数据,recv可以返回接收到的数据。
如下图所示,进程在recv阻塞期间,计算机收到了对端传送的数据(步骤①)。数据经由网卡传送到内存(步骤②),然后网卡通过中断信号通知cpu有数据到达,cpu执行中断程序(步骤③)。此处的中断程序主要有两项功能,先将网络数据写入到对应socket的接收缓冲区里面(步骤④),再唤醒进程A(步骤⑤),重新将进程A放入工作队列中。
唤醒进程的过程如下图所示。
以上是内核接收数据全过程
这里留有两个思考题,大家先想一想。
其一,操作系统如何知道网络数据对应于哪个socket?
其二,如何同时监视多个socket的数据?
第一个问题:因为一个socket对应着一个端口号,而网络数据包中包含了ip和端口的信息,内核可以通过端口号找到对应的socket。当然,为了提高处理速度,操作系统会维护端口号到socket的索引结构,以快速读取。
第二个问题则是epoll等内核技术来解决
Unix提供了5种不同的I/O模型,分别是
一个I/O操作需要从用户态进入内核态运行,通常包括俩阶段
对于socket I/O而言,第一步通常是等待数据从网络中到达,到达之后会复制到内核的某个缓冲区
第二步就是从内核缓冲区复制到应用进程缓冲区
默认情况下,所有的socket都是阻塞的. 如图所示
这也是理解和编程起来比较简单的模型,所以计算机早期用的很多,现在在处理超大文件的时候,也依然适用于这种模型。
相比于Blocking I/O
, Non-Blocking I/O
的等待数据阶段不会被阻塞,也就是说操作系统不会挂起应用, 应用
不断轮询(polling)
内核看是否数据准备好。某次轮询发现准备好了,再直接发系统调用阻塞取数据.
Unix网络编程
里对轮询
的定义是:
应用进程对非阻塞描述符循环发送系统调用,以查看某个操作是否就绪
在处理非常多的描述符的时候,I/O多路复用技术显得非常有用。I/O多路复用需要发送2次系统调用:
在只有1个客户端的时候,I/O多路复用技术甚至不如阻塞I/O.因为多发了一次系统调用。
但有些常用的网络场景,如:
这些场景下,多发的这次系统调用能带来更高的I/O处理效率,能更均匀的使用服务器时间片, 处理更多连接。
常用的方法是把I/O多路复用和非阻塞I/O结合使用,这样应用进程不需要阻塞,能处理别的业务,同时又能够处理多个I/O请求
这里可以看出,多路复用也是在第一阶段生效,也就是说进程阻塞在多路复用器epoll上,当数据从缓冲区读到内核完毕后,进程被唤醒
但是第二步,进程发起系统调用,将数据从内核buffer read到用户空间,这一步仍然是阻塞的,但是这一步通常都很快(比如一个socketChannel 一端连接的是一个socket,一端是buffer,所以get(buffer)就是将socker 在内核中的数据读到用户空间的buffer)
信号驱动模型类似于你去一点点买奶茶,对方给你一个小票。
什么时候好了对方喊xx号(这就是信号)好了.然后你来取奶茶(recvfrom取数据)
AIO模型是比较高效的, 异步非阻塞,从各个方面来说都是。但是编程难度比较大(和人的思维惯性相左),代码写起来不易维护,可能未来会火吧。目前I/O多路复用已经足够用了。
前四种模型的区别主要在第一阶段, 第二阶段recvfrom将数据从内核复制到应用的缓冲区期间,都会阻塞应用进程。
而异步I/O模型在两个阶段都要处理.
Non-Blocking I/O
和Signal-Driven I/O
在数据准备阶段都不会阻塞,前者要轮询内核数据是否准备好,后者是直接等待内核通知回调
Asynchronous I/O
是在真正意义上的POSIX
定义的异步io操作, 在数据准备和复制阶段都不会阻塞应用,但是编程难度大
I/O Multiplexing
在数据准备阶段也会阻塞,但是可以处理更多I/O请求,也就是说加了层中间人抽象,虽然阻塞了应用进程,但是能知道多个fd可读可写,也是大名鼎鼎的reactor模式
的基础(在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。)
使用select, 我们即使应用进程里只有1个线程也能够接受多个连接并且做出处理,因为应用进程不需要阻塞在socket的read,write或者accept系统调用上, 而是内核告诉应用有事件到来了, 应用进程遍历fd_set
看是哪个fd
就绪了,然后再处理
fd_set
称为描述符集
,通常是一个整数数组
,其中每个整数的每一位对应1个描述符的状态.
select的函数签名是:
#include
#include
int select(int maxfdpl, fd_set *readset, fd_set *writeset,
fd_set *exceptset, const struct timeval *timeout)
其中,返回值表示fd_set
里就绪的元素总个数,包括读,写,异常fd_set
第一个参数表示待测试的fd个数,值一般是待测试的fd总数+1
中间仨代表要监听的读set, 写set和异常set
最后一个参数代表select每个fd经历的时间
操作描述符集
的方法是4个宏:
select的fd_size(最大描述符数)只有1024bit, 是在头文件里用宏写死的,
早期bsd内核最多只能开20个进程,在当时看来1024长度的fd_set已经大到用不完了, 放在今天肯定是不够了
一旦fd_set里任意fd上有事件发生,内核会立刻返回,将数据从内核空间拷贝到用户空间,然后应用进程需要遍历整个
fd_set去寻找哪个fd有事件了。
所以,select的缺点主要有:
poll只是解决了文件描述符的限制,而没有解决以下问题:
这俩性能问题在Banga在1999年写了篇论文A Scalable and Explicit Event
Delivery Mechanism for UNIX,提出select
和poll
都是无状态的,需要用户空间的进程自行遍历查找事件, 一种改进方案是内核内部自己维护事件集合.通过一个类似declare_interest
的系统调用,内核能够增量得更新进程感兴趣的事件集合列表, 应用进程通过使用get_next_event
调用能派发新事件给内核。
根据论文的研究成果,LINUX
和FreeBSD
各自给出的解决方案:epoll
和kqueue
.我们主要讨论epoll, 毕竟日常服务端环境都是LINUX.
在LINUX内核2.6以上,epoll
才受到支持。
epoll操作过程有3个函数
#include
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
epoll_create
创建一个epoll fd
和事件表, 在LINUX2.6.8以后是使用红黑树来管理epoll事件表,所以size没有太大作用epoll_ctl
操作上面创建的epoll事件表, 可以加入socket读写事件epoll_wait
类似于以前的select
和poll
,得到发生的事件, 如socket可读可写epoll_ctl
的第二个参数使用3个宏来表示动作:
EPOLL_CTL_ADD
: 注册新的fd到epfd中EPOLL_CTL_MOD
: 修改意见注册的fd的监听事件EPOLL_CTL_DEL
: 从epfd中删除一个fdepoll事件和以前poll
的事件类型差不多,主要还是这仨:
EPOLLIN
: 对应的fd可读EPOLLOUT
: 对应的fd可写EPOLLERR
: 对应的fd发送错误#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#define MAX_FD_NUM 1024
#define MAXLEN 1024
int buf_len = 0;
int main()
{
// 1. 新建socket, 用于监听端口
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
if (listenfd == -1) printf("创建socket失败, error: %s (errno: %d)\n", strerror(errno), errno);
// 2. 绑定端口
unsigned short listenPort = 8090;
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(listenPort);
int on = 1;
// 设置socket绑定的端口,再程序关闭之后可以重复使用
if ((setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(int))) < 0) {
exit(1);
}
int bindRet = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
if (bindRet == -1) {
printf("socket绑定地址失败, error: %s (errno: %d)\n", strerror(errno), errno);
exit(1);
}
// 3. 监听端口
int listenRet = listen(listenfd, 10);
if (listenRet == -1) printf("socket监听端口失败, error: %s (errno: %d)\n", strerror(errno), errno);
printf("socket 监听完毕, 地址: 127.0.0.1:%d", listenPort);
struct sockaddr_in client_addr;
socklen_t client_addr_len = sizeof(struct sockaddr_in);
// 4. 创建一个 epfd,并且把 listenfd 注册到这个 epfd上。
int epfd = epoll_create(1024);
struct epoll_event ev,events[20];
ev.data.fd = listenfd;
ev.events = EPOLLIN;
epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);
int cur_fd_num = 1;
char buf[MAXLEN]={0};
while (1) {
// 5. 调用epoll_wait获取IO事件
// nReady 就是 events 数组的长度。
int nready = epoll_wait(epfd, events, 20, 50);
int i = 0;
for (; i < nready; i++) {
if (events[i].data.fd == listenfd) {
int client_sockfd = accept(listenfd,(struct sockaddr*)&client_addr,&client_addr_len);
if(client_sockfd < 0) {
perror("accept");
}
else {
printf("accept client_addr %s\n",inet_ntoa(client_addr.sin_addr));
ev.data.fd = client_sockfd;
ev.events=EPOLLIN;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_sockfd, &ev);
}
}
else if (events[i].events & EPOLLIN) {
int connfd = events[i].data.fd;
int n = recv(connfd, buf, MAXLEN, 0);
if(n <= 0) {
if(ECONNRESET == errno) {
close(connfd);
epoll_ctl(epfd, EPOLL_CTL_DEL, connfd, 0);
}
else {
perror("recv");
}
}
printf("receive %s", buf);
buf_len = n;
ev.data.fd = connfd;
ev.events = EPOLLOUT;
epoll_ctl(epfd, EPOLL_CTL_MOD, connfd, &ev);
}
else if (events[i].events & EPOLLOUT) {
int connfd = events[i].data.fd;
write(connfd, buf, buf_len);
ev.data.fd = connfd;
ev.events = EPOLLIN;
epoll_ctl(epfd, EPOLL_CTL_MOD, connfd, &ev);
}
}
}
return 0;
}
使用gcc编译,运行
$ gcc -o epoll ./epoll.c
$ ./epoll
然后使用 nc
测试即可
epoll
解决了select
和poll
时代遗留的2个性能问题,不需要使fd数组整体在内核空间和用户空间之间来回拷贝,同时
不需要应用进程遍历整个fd数组以查找发生的事件。epoll
使用mmap
加速了内核和用户空间的消息传递(主要是rdlist和 事件树?),避免不必要的内存拷贝。
epoll
只会返回活跃的socket fd,所以I/O效率不会随着fd数目增加而显著下降。
如下图所示,当某个进程调用**epoll_create
方法时,内核会创建一个eventpoll
对象(也就是程序中epfd所代表的对象)。eventpoll
对象也是文件系统中的一员(*也是一个文件,有fd*),和socket一样,它也会有等待队列。**
*内核创建eventpoll对象*
创建一个代表该epoll的eventpoll
对象是必须的,因为内核要维护“就绪列表”等数据,“就绪列表”可以作为eventpoll
的成员。
重要:当socket收到数据后,中断程序会操作eventpoll对象,而不是直接操作进程。
Epoll中主要三类事件,可读,可写和发送错误
对于可读来说,socket收到客户端信息变为可读
可写,就是socket缓冲区不满都属于可写
所以编程上一般epoll_wait返回的时候,先判断可读,然后判断可写(因为只要缓冲区不满就一直可以写,或者说写缓冲区的东西被发送走了,就会触发可写事件)
当socket收到数据后,中断程序会给eventpoll的“就绪列表”添加socket引用。如下图展示的是sock2和sock3收到数据后,中断程序让rdlist引用这两个socket。
给就绪列表添加引用
eventpoll对象相当于是socket和进程之间的中介,socket的数据接收并不直接影响进程,而是通过改变eventpoll的就绪列表来改变进程状态。
当程序执行到epoll_wait时,如果rdlist已经引用了socket,那么epoll_wait直接返回,如果rdlist为空,阻塞进程。
阻塞和唤醒进程
假设计算机中正在运行进程A和进程B,在某时刻进程A运行到了epoll_wait语句。如下图所示,内核会将进程A放入eventpoll的等待队列中,阻塞进程。
当socket接收到数据,中断程序一方面修改rdlist,另一方面唤醒eventpoll等待队列中的进程,进程A再次进入运行状态(如下图)。也因为rdlist的存在,进程A可以知道哪些socket发生了变化。
epoll唤醒进程
也就是epoll一直等待在 socket上,而进程 等待在epoll上。
首先,linux 的 file 有个 pollable 的概念,只有 pollable 的 file 才可以加入到 epoll 和 select 中。一个 file 是 pollable 的当且仅当其定义了 file->f_op->poll。file->f_op->poll 的形式如下
__poll_t poll(struct file *fp, poll_table *wait)
不同类型的 file 实现不同,但做的事情都差不多:
在 callback 中会将另外一个 callback2[[2]] 注册到 waitqueue[[3]]中,此后 fp 有触发事件就会调用 callback2
waitqueue 是事件驱动的,与驱动程序密切相关,简单来说 poll 函数在 file 的触发队列中注册了个 callback, 有事件发生时就调用callback。
下面来看看Linux内核具体的epoll机制实现思路。
当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关。eventpoll结构体如下所示:
struct eventpoll{
....
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
....
}
每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度)。
而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist
双链表中
。
在epoll中,对于每一个事件,都会建立一个epitem结构体,如下所示:
struct epitem{
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}
当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。
因为所有添加到 epoll中的事件都会与 网卡建立回调关系,当事件发生后会调用回调
ep_poll_callback
Epoll的核心原理很简单,使用内核的等待队列机制等待队列中发生事情,向等待队列注册一个回调函数。当等待队列中有事件发生,由*下层调用(socket fd调用)等待队列中的回调函数,这个回调函数的作用就是把发生事件的fd放到ready list中*,然后epoll每次都只是返回ready list中的所有已经发生的事件。**
下层一个fd,例如socket,会在收到数据的时候检查自己的等待队列sk->sk_wq,查看有没有人在等待socket事件的发生(epoll在等待多个fd的多个事件,如果发现有epoll在等待这个事件),如果有,就唤醒这个队列,并且调用epoll的回调函数ep_poll_callback,
该函数会把该socket的fd(在epoll中对应epitem结构体)放到epoll的等待队列
,从而epoll每次都可以只拿到一个已经全部就绪的列表。这样就没有遍历的过程了。这也是epoll的核心技术点。*但是epoll这样要求所有的fd都要有检查唤醒等待队列的能力,也就是自下而上传导事件的能力。也就是因此,内核中已经建立了完整的自下而上的事件通知链条,所有的fd都支持*。
ep_poll_callback
函数核心功能是将被目标fd的就绪事件到来时,将fd对应的epitem实例添加到就绪队列。当应用调用epoll_wait()时,内核会将就绪队列中的事件报告给应用。
流程:网卡—>中断程序—>内核协议栈程序—>放入对应socket缓冲区—>调用对应socket waitQueue的callback—>操作eventpoll结构体,将自身加入rdlist—>epoll_wait返回
Linux上进行TCP Socket编程的时候,通常流程是
监听Socket
(ServerSocket),系统调用就是int **socket**(int protofamily, int type, int protocol);//返回sockfd
地址和端口号组合
赋给socket。int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
int listen(int sockfd, int backlog)
listen函数的第一个参数即为要监听的socket描述字,第二个参数为相应socket可以排队的最大连接个数。,这里表示 使用 监听Socket来开始监听,也就是在之前绑定的端口监听int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
返回建立连接的新的fd–socketfd_new此时我们需要区分两种套接字,
监听套接字: 监听套接字正如accept的参数sockfd,它是监听套接字,在调用listen函数之后,是服务器开始调用socket()函数生成的,称为监听socket描述字(监听套接字)
连接套接字:一个套接字会从主动连接的套接字变身为一个监听套接字;而accept函数返回的是已连接socket描述字(一个连接套接字),它代表着一个网络已经存在的点点连接。
一个服务器通常通常仅仅只创建一个监听socket描述字,它在该服务器的生命周期内一直存在。内核为每个由服务器进程接受的客户连接创建了一个已连接socket描述字,当服务器完成了对某个客户的服务,相应的已连接socket描述字就被关闭。
自然要问的是:为什么要有两种套接字?原因很简单,如果使用一个描述字的话,那么它的功能太多,使得使用很不直观,同时在内核确实产生了一个这样的新的描述字。
连接套接字socketfd_new 并没有占用新的端口与客户端通信,依然使用的是与监听套接字socketfd一样的端口号
其实新建立的Socket没有监听端口,只是对接了内核缓冲区;所以可以读写数据
首先,一个端口肯定只能绑定一个socket。服务器端的端口在bind的时候已经绑定到了监听套接字socetfd所描述的对象上,accept函数新创建的socket对象其实并没有进行端口的占有,而是复制了监听套接字socetfd的本地IP和端口号
,并且记录了连接过来的客户端的IP和端口号。
那么,当客户端发送数据过来的时候,究竟是与哪一个socket对象通信呢?
客户端发送过来的数据可以分为2种,一种是连接请求,一种是已经建立好连接后的数据传输。
由于TCP/IP协议栈是维护着一个接收和发送缓冲区的。在接收到来自客户端的数据包后,服务器端的TCP/IP协议栈应该会做如下处理
:如果收到的是请求连接的数据包,则传给监听着连接请求端口的socetfd套接字,进行accept处理;如果是已经建立过连接后的客户端数据包,则将数据放入接收缓冲区。这样,当服务器端需要读取指定客户端的数据时,则可以利用socketfd_new套接字通过recv或者read函数到缓冲区里面去取指定的数据(因为socketfd_new代表的socket对象记录了客户端IP和端口,因此可以鉴别)。