我是靠谱客的博主 舒心眼神,最近开发中收集的这篇文章主要介绍【nginx】网络通讯实战二,觉得挺不错的,现在分享给大家,希望可以做个参考。

概述

监听端口实战、epoll介绍及原理详析

一、监听端口

在创建worker进程之前就要执行函数ngx_open_listening_sockets(),先创建套接字,再setsockopt防止TIME_WAIT,再ioctl设置非阻塞,然后listen,bind,将监听的套接字放入m_ListenSocketList容器。

二、epoll技术概述

1、I/O多路复用:epoll就是一种典型的I/O多路复用技术,epoll技术的最大特点是支持高并发

传统多路复用技术select,poll,在并发量达到1000-2000性能就会明显下降;epoll,从linux内核2.6引入的,2.6之前是没有的。

2、epoll和kqueue技术类似:单独一台计算机支撑少则数万,多则数十上百万并发连接的核心技术。

epoll技术完全没有性能会随着并发量提高而出现明显下降的问题。但是并发每增加一个,必定要消耗一定的内存去保存这个连接相关的数据;并发量总还是有限制的,不可能是无限的。

3、10万个连接同一时刻,可能只有几十上百个客户端给你发送数据,epoll只处理这几十上百个客户端。

4、很多服务器程序用多进程,每一个进程对应一个连接;也有用多线程做的,每一个线程对应一个连接;

epoll事件驱动机制,在单独的进程或者单独的线程里运行,收集/处理事件;没有进程/线程之间切换的消耗,高效。

5、适合高并发,开发难度极大。

三、epoll原理与函数介绍:三个函数(操作系统提供的,只会用就行)

1、epoll_create()函数

int epoll_create(int size);    //size>0就行

【功能】创建一个epoll对象,返回该对象的文件描述符,这个描述符就代表这个epoll对象,最终要用close()关闭。

【原理】

a)struct eventpoll *ep = (struct eventpoll*)calloc(1, sizeof(struct eventpoll)); 分配一段内存

b)rbr结构成员:代表一颗红黑树的根节点[刚开始指向空]把rbr理解成红黑树的根节点的指针;

红黑树,用来保存键(数字)/ 值(结构),能够快速的通过key取出键值对。

c)rdlist结构成员:代表一个双向链表的表头指针;

双向链表:从头访问/遍历每个元素特别快,一直next就行。

d)总结:创建了一个eventpoll结构对象,被系统保存起来:

rbr成员被初始化成指向一颗红黑树的根(有了一个红黑树);

rdlist成员被初始化成指向一个双向链表的根(有了双向链表)。

2、epoll_ctl()函数

int epoll_ctl(int efpd,int op,int sockid,struct epoll_event *event);

【功能】把一个socket以及这个socket相关的事件添加到这个epoll对象描述符中去,目的就是通过这个epoll对象来监视这个socket(客户端的TCP连接)上数据的来往情况,当有数据来往时,系统会通知我们;我们把感兴趣的事件通过epoll_ctl()添加到系统,当这些事件来的时候,系统会通知我们。

【字段说明】

efpd:epoll_create()返回的epoll对象描述符;

op:动作,添加/删除/修改,对应数字是1,2,3,EPOLL_CTL_ADD、EPOLL_CTL_DEL、EPOLL_CTL_MOD

EPOLL_CTL_ADD:添加事件,往红黑树上添加一个节点,每个客户端连入服务器后,服务器都会产生一个对应的socket,对应每个连接的这个socket值肯定都不重复;这个socket就是红黑树中的key,把这个节点(其实是一个结构)挂到红黑树上去。

EPOLL_CTL_MOD:修改事件,用了EPOLL_CTL_ADD把节点添加到红黑树上之后才存在修改。

EPOLL_CTL_DEL:是从红黑树上把这个节点干掉,这会导致这个socket(这个tcp链接)上无法收到任何系统通知事件。

sockid:想读时就是监听套接字,想写时就是从accept()返回的sockfd,这个sockid也就是红黑树里边的key。

event:事件信息,EPOLL_CTL_ADD和EPOLL_CTL_MOD都要用到这个event参数里边的事件信息。

【原理】假设是ADD,先判断有没有这个key,生成一个epitem对象(一个结点),把socket和事件保存在结点中,把结点加入红黑树。每一个epi就是一个指向epitem的指针,rbn中有三个指针,分别指向左孩子、右孩子和父亲。

epi = (struct epitem*)calloc(1, sizeof(struct epitem));

epi = RB_INSERT(_epoll_rb_socket, &ep->rbr, epi); 【EPOLL_CTL_ADD】,增加节点到红黑树中;

epi = RB_REMOVE(_epoll_rb_socket, &ep->rbr, epi);【EPOLL_CTL_DEL】,从红黑树中把节点干掉;

EPOLL_CTL_MOD,找到红黑树节点,修改这个节点中的内容。

【面试】

EPOLL_CTL_ADD:等价于往红黑树中增加节点

EPOLL_CTL_DEL:等价于从红黑树中删除节点

EPOLL_CTL_MOD:等价于修改已有的红黑树的节点

当事件发生,我们如何拿到操作系统的通知?

3、epoll_wait()函数

int epoll_wait(int epfd,struct epoll_event *events,int maxevents,int timeout);

【功能】阻塞一小段时间并等待事件发生,返回事件集合,也就是获取内核的事件通知。

因为双向链表里记录的是所有有数据/有事件的socket(TCP连接),所以具体做法是遍历这个双向链表,把这个双向链表里边的节点数据拷贝出去,拷贝完毕的就从双向链表里移除。

【字段说明】

epfd:epoll_create()返回的epoll对象描述符;

events:是内存,也是数组,长度是maxevents,表示此次epoll_wait调用可以收集到的maxevents个已经就绪【已经准备好的】的读写事件,也就是返回的是实际发生事件的tcp连接数目。

参数timeout:阻塞等待的时长,没数据等待这么久,有数据直接返回;

epitem结构设计的高明之处:既能够作为红黑树中的节点,又能够作为双向链表中的节点(rdlink)。

epoll_wait的作用就是从双向链表中把有事件发生的连接取出来再read或write等,只有发生某个事件的连接才在双向链表中。

【原理】等一段时间,这段时间是用来把sockfd扔到双向链表中,取得事件数量(给的空间和来的数量取小的),每次从双向链表头中一个一个取(但在红黑树中还存在),rdy=0表示不在双向链表中,把事件拷贝到提供的events中。

4、内核向双向链表增加节点

一般有四种情况,会使操作系统把节点插入到双向链表中:

a)客户端完成三路握手;服务器要accept()从已完成队列中取走连接;

b)当客户端关闭连接,服务器也要调用close()关闭;

c)客户端发送数据来的;服务器要调用read(),recv()函数来收数据;

d)当可以发送数据时;服务器可以调用send(),write()来发送数据。

 

通讯代码精粹之epoll函数实战1

一、配置文件的修改

增加worker_connections项:允许连接的最大并发数(1024)

二、epoll函数实战

epoll_create()、epoll_ctl()、epoll_wait()系统提供的函数调用

1、ngx_epoll_init函数内容

1)epoll_create()

创建一个epoll对象,创建了一个红黑树,还创建了一个双向链表;

2)创建连接池

【连接池】就是一个数组,元素数量就是worker_connections(1024),每个数组元素类型为 ngx_connection_t(结构)。

为什么要引入这个数组?2个监听套接字, 用户连入进来,每个用户多出来一个套接字(sockfd);套接字只是一个数字,把套接字数字跟一块内存捆绑,达到的效果就是将来我通过这个套接字,就能够把这块内存拿出来。

把数组中每一项的一个内容指向下一个数组元素,相当于串起来的一个空闲链表,这样当来了一个连接,需要取一个套接字时,就不需要遍历1024个连接找空的了,直接把链表头的连接取出应对客户端连接,用完了再放回链表头。

3)遍历所有监听端口,为每个端口分配一个连接池中的连接来对应

调用ngx_get_connection()重要函数从连接池中找空闲连接,非常快!

4)epoll_ctl的EPOLL_CTL_ADD来往红黑树里加结点

lsof -i:80 命令可以列出哪些进程在监听80端口

2、ngx_epoll_init函数的调用(要在子进程中执行)

【具体过程】父进程先监听80和443端口,创建子进程过程中进行epoll相关初始化,调用ngx_epoll_init函数为每个进程开辟一个1024个元素的数组,遍历所有socket,将其对应c[0](空闲池的一个位置),相当于每个socket都对应了连接池的一项。然后,调ngx_epoll_add_event(里面是epolll_ctl)这种函数来往对应socket上增加事件,可能是读可能是写,然后就不用管了,这样如果有客户端来连接内核就会识别到从epoll_wait中往下走。

具体是怎么走的呢?

客户端连接请求到来时,内核从红黑树中找对应的文件描述符,将对应的结点(epitem)加入双向链表,然后epoll_wait将改变的事件放入events(epoll_wait返回events),再遍历events调用accept函数完成三次握手。所以对于写程序的人,只需要在子进程中循环调用epoll_wait并对返回的events遍历,调相关的如accpet一类针对监听套接字的函数即可。

 

通讯代码精粹之epoll函数实战2

一、ngx_epoll_process_events函数调用位置

这个函数,仍旧是在子进程中被调用,被放在了子进程的for (;;),意味着这个函数会被不断的调用。

二、ngx_epoll_process_events函数内容

用户三次握手成功连入进来,这个“连入进来”这个事件对于服务器来讲,就是一个监听套接字上的可读事件。

三:ngx_event_accept函数内容

epoll_wait走下来就去执行这个函数去accept,生成用来传数据的服务端套接字!

a)accept4/accept:注意设置成非阻塞;

b)ngx_get_connection:从连接池中取一项,对新套接字绑定一块内存;

c)ngx_epoll_add_event:对新套接字绑定处理函数rhandler

【epoll的两种工作模式:LT和ET】

LT:level trigged, 水平触发,这种工作模式是低速模式(效率差),epoll缺省用此模式。

ET:edge trigged,边缘触发/边沿触发,这种工作模式是高速模式(效率好)

水平触发:来一个事件,如果你不处理它,那么这个事件就会一直被触发;具体,因为是水平触发而且worker死循环里调用epoll_wait,所以epoll_wait每次都触发了事件,每次都走下来,如果不用函数处理,每次都提醒你有三次握手连入;

边缘触发:只对非阻塞socket有用,来一个事件,内核只会通知你一次(不管是否处理,内核都不再次通知);边缘触发模式,提高系统运行效率,编码的难度加大;因为只通知一次,所以接到通知后,你必须要保证把该处理的事情处理利索。比如客户端发来了100B的数据,结果只处理了70B,还有30B在收缓冲区中没读,这时,如果是水平触发,就还会收到去读数据的事件,会不停提醒直到读完,但如果是边缘触发的话,自己没处理干净,就得等下次再有别的什么东西触发这个事件去读那30B。

现状:所有的监听套接字用的都是水平触发;所有的接入进来的用户套接字都是边缘触发。

小tips:有可能第一个事件是关闭连接的,这时c->fd = -1,然后第三个事件是跟第一个事件操作相同的套接字,这时要判断一下,如果c->fd 等于-1就continue,这样可以过滤过期事件。

四、总结和测试

1、服务器能够感知到客户端发送过来abc字符了

2、来数据会调用ngx_wait_request_handler(),在屏幕上打印一行222222222(往errno打印)

五、事件驱动总结:nginx所谓的事件驱动框架(面试可能问到)

【总结事件驱动框架/事件驱动架构】

所谓事件驱动框架,就是由一些事件发生源(三次握手内核通知或客户端发来数据,事件发生源就是客户端,),通过事件收集器来收集和分发事件(调用函数处理),事件收集器:epoll_wait()函数,ngx_event_accept(),ngx_wait_request_handler(),都属于事件处理器,用来消费事件。

ngx_epoll_process_events就是中间的圆柱!

六、一道腾讯后台开发的面试题

【问题】使用Linux epoll模型,水平触发模式;当socket可写时,会不停的触发socket可写的事件,如何处理(就是发缓冲区发满,系统就会告诉你可写可写可写)?

【答案】第一种最普遍的方式:需要向socket写数据的时候才把socket加入epoll(红黑树),等待可写事件。接受到可写事件后,调用write或者send发送数据,当所有数据都写完后,把socket移出epoll。这种方式的缺点是,即使发送很少的数据,也要把socket加入epoll,写完后在移出epoll,有一定操作代价。

 一种改进的方式:开始不把socket加入epoll,需要向socket写数据的时候,直接调用write或者send发送数据。如果返回EAGAIN(可能数据多了就返回这个?),把socket加入epoll,在epoll的驱动下写数据,全部数据发送完毕后,再移出epoll。这种方式的优点是:数据不多的时候可以避免epoll的事件处理,提高效率。

 

ET、LT深释,服务器设计、粘包解决

一、’ET,LT模式深入分析及测试

【回顾】

LT:水平触发/低速模式,这个事件没处理完,就会被一直触发;

ET:边缘触发/告诉模式,这个事件通知只会出现一次。

普遍认为ET比LT效率高一些,但是ET编程难度比LT大一些;

【测试】

//ET测试代码
unsigned char buf[10]={0};
memset(buf,0,sizeof(buf));    
do
{
    int n = recv(c->fd,buf,2,0); //每次只收两个字节    
    if(n == -1 && errno == EAGAIN)
        break; //数据收完了
    else if(n == 0)
        break; 
    ngx_log_stderr(0,"OK,收到的字节数为%d,内容为%s",n,buf);
}while(1);*/

//LT测试代码
unsigned char buf[10]={0};
memset(buf,0,sizeof(buf));  
int n = recv(c->fd,buf,2,0);
if(n  == 0)
{
    //连接关闭
    ngx_free_connection(c);
    close(c->fd);
    c->fd = -1;
}
ngx_log_stderr(0,"OK,收到的字节数为%d,内容为%s",n,buf);
   

客户端发abcdefg,ET模式下,如果每次触发只用recv收2个字节,那么需要触发4次才可以全部收完(注意字符串最后有'',用telnet还可能有回车符n),LT模式下,只发送一次即可;如果没有数据可接收,则recv会返回-1。

【思考】

为什么ET模式事件只触发一次?事件被扔到双向链表中一次,被epoll_wait取出后就干掉了。

为什么LT模式事件会触发多次呢?事件如果没有处理完,事件会被多次往双向链表中扔,或者就根本没删除,一直在链表中。

所以LT模式的双向链表中表项要不ET的多一些,所以效率会低一些。

如何选择ET还是LT?如果收发数据包有固定格式,建议采取LT,编程简单,清晰,写好了效率不见得低,因为ET反正也要不断地调用recv;如果收发数据包没有固定格式,可以考虑采用ET模式(nginx官方服务器就是没有固定格式的,用ET)。

二、我们的服务器设计

1、服务器设计原则总述

通用的服务框架:稍加改造甚至不用改造就可以把它直接应用在很多的具体开发工作中,工作重点就可以聚焦在业务逻辑上。

2、收发包格式问题提出

比如,网络打拳游戏,第一条命令出拳【1abc2】,第二条加血【1def2|30】,命令:1abc21def2|30。

3、TCP粘包、缺包

【TCP粘包问题】

举例:client发送abc,def,hij,三个数据包发出去:

a)客户端粘包现象

比如send("abc"); write()也可以,send("def");,send("hij");,因为客户端有一个Nagle优化算法(当每个数据包数据很小时把几个包合成一个包发送,因为如果不这么做每个包都要加很多tcp头、ip头、以太网帧头信息,不划算),这三个数据包被Nagle优化算法直接合并一个数据包发送出去,这就属于客户端粘包;

如果关闭Nagle优化算法,那么调用几次send()就发送出去几个包,那客户端的粘包问题就解决了。

b)服务器端粘包现象

不管客户端是否粘包,服务器端都存在粘包的问题。

服务器端两次recv之间可能间隔100毫秒,那可能在这100毫秒内,客户端这三个包都到了,这三个包都被保存到了服务器端的针对该TCP连接收数据缓冲中(abcdefhij),此时再次recv一次,就可能拿到了全部的“abcdefhij”,这就叫服务器端的粘包。

【再举一例:缺包】

send("abc......."),8000字节;这个可能被操作系统拆成6个包(MTU=1400B)发送出去了,网络可能出现延迟或者阻塞,服务器端第一次recv() = "ab",recv() = "c...",... ,recv() = ".....de",也就是断断续续地掉几十次recv才能收到所有数据。

4、TCP粘包、缺包解决【面试】

粘包:要解决的就是吧这几个包拆出来,一个是一个;解决粘包的方案很多,比如加一些特殊字符abc$def$hij,服务器程序员不能假设收到的数据包都是善意的、合理的,构造畸形数据包abc#def-hij就会让此服务器程序出问题,万万不可!

如何解决拆包问题:给收发的数据包定义一个统一的格式[规则];c/s都按照这个格式来,就能够解决粘包问题。

包格式:包头+包体的格式,其中包头是固定长度(10B),在包头中,有一个成员变量会记录整个包(包头+包体)的长度;这样的话,先收包头,从包头中,我知道了整个包的长度,然后用整个包的长度 - 10个字节 = 包体的长度。我再收“包体的长度”这么多的字节,收满了包体的长度字节数,我就认为,一个完整的数据包(包头+包体)收完。

【收包总结】

(1)先收固定长度包头 10字节;

(2)收满后,根据包头中的内容,计算出包体的长度:整个长度-10

(3)我再收包体长度这么多的数据,收完了,一个包就完整了;

我们就认为受到了一个完整的数据包;从而解决了粘包的问题。

【注意】官方的nginx的代码主要是用来处理web服务器(一种专用的服务器),代码写的很庞杂;不太适合这种固定数据格式(包头+包体)的服务器(通用性强的服务器,可以应用于各种领域,但是也不太适合做web服务器)。

 

通讯代码精粹之收包解包实战

一、收包分析及包头结构定义

发包:采用包头+包体,其中包头中记录着整个包(包头+包体)的长度。

a)一个包的长度不能超过30000个字节,必须要有最大值,防止恶意数据包使服务器端无限等待300亿个字节;

b)开始定义包头结构:COMM_PKG_HEADER

//包头结构
typedef struct _COMM_PKG_HEADER
{
    unsigned short pkgLen;    //报文总长度【包头+包体】--2字节
                              //2字节可以表示的最大数字为6万多,_PKG_MAX_LENGTH 30000 够用了
	                          //包头中记录着整个包【包头+包体】的长度
    unsigned short msgCode;   //消息类型代码--2字节,用于区别每个不同的命令【不同的消息】
    int            crc32;     //CRC32效验--4字节
                              //防止收发数据中出现收到内容和发送内容不一致的情况,引入这个字段做一个基本的校验
}COMM_PKG_HEADER,*LPCOMM_PKG_HEADER;

c)结构字节对齐问题:为了防止出现字节问题,所有在网络上传输的这种结构,必须都采用1字节对齐方式。

//结构定义------------------------------------
#pragma pack (1)    //对齐方式,1字节对齐【结构之间成员不做任何字节对齐:紧密的排列在一起】

//...

#pragma pack()    //取消指定对齐,恢复缺省对齐

二、收包状态宏定义

收包:粘包,缺包;

收包思路:先收包头 -> 根据包头中的内容确定包体长度并收包体,收包状态(类似状态机),定义4种收包的状态:

#define _PKG_HD_INIT         0  //初始状态,准备接收数据包头
#define _PKG_HD_RECVING      1  //接收包头中,包头不完整,继续接收中
#define _PKG_BD_INIT         2  //包头刚好收完,准备接收包体
#define _PKG_BD_RECVING      3  //接收包体中,包体不完整,继续接收中,处理后直接回到_PKG_HD_INIT状态

三、收包实战代码

主要的三个用于收包的变量:

char          dataHeadInfo[_DATA_BUFSIZE_];   //用于保存收到的数据的包头信息
char          *precvbuf;                      //就是所收到数据要保存的位置
unsigned int  irecvlen;                       //要收到多少数据由这个变量指定,和precvbuf配套使用

聚焦在ngx_wait_request_handler()函数(epoll_wait走下来调用的),同时设置好各种收包的状态(根据包头再判断后续再取多少个字节):

c->curStat = _PKG_HD_INIT;                //收包状态处于初始状态,准备接收数据包头
c->precvbuf = c->dataHeadInfo;            //因为要先收包头,所以收数据的位置就是dataHeadInfo
c->irecvlen = sizeof(COMM_PKG_HEADER);    //这里指定收数据的长度,先要求收包头这么长字节的数据

要求:客户端连入到服务器后,要主动地(客户端有义务)给服务器先发送数据包,服务器要主动收客户端的数据包,服务器按照 包头 + 包体的格式来收包。

引入一个消息头(结构)STRUC_MSG_HEADER,用来记录一些额外信息;服务器收包时除了包头和包体,再额外附加一个消息头 ==》消息头 + 包头 + 包体;消息头主要用来处理过时包等问题!

增加一个分配和释放内存类CMemory;

【内存池问题】本项目中不考虑内存池,内存池主要功能是频繁的分配小块内存时可以节省额外内存开销(代价就是代码更复杂),new速度已经很快了,用内存池不方便。

【代码】recvproc调用recv函数,recv返回0表示正常4次挥手断开连接,recv返回-1表示出错,recv返回n表示正常为收到字节数n。再用如下类似代码取包头数据,再取包体数据,因为包体大小是未知的,所以要在ngx_wait_request_handler_proc_p1(包头收完了调用)中分配内存来存包体,并设置接收数据状态为_PKG_BD_INIT,设置接收数据位置和要收的数据大小(包体大小)。同理如果包体也收完了,就调用ngx_wait_request_handler_proc_plast函数去把消息头+包头+包体放入消息队列。

判断是否收完方法:要收的(irecvlen)等于收到的(reco)字节数。

if(c->curStat == _PKG_HD_INIT) //连接建立起来时肯定是这个状态,因为在ngx_get_connection()中已经把curStat成员赋值成_PKG_HD_INIT了
{        
    if(reco == m_iLenPkgHeader)//正好收到完整包头,这里拆解包头
    {   
        ngx_wait_request_handler_proc_p1(c); //那就调用专门针对包头处理完整的函数去处理把。
    }
    else
    {
        //收到的包头不完整--我们不能预料每个包的长度,也不能预料各种拆包/粘包情况,所以收到不完整包头【也算是缺包】是很可能的;
        c->curStat = _PKG_HD_RECVING;                 //接收包头中,包头不完整,继续接收包头中	
        c->precvbuf = c->precvbuf + reco;              //注意收后续包的内存往后走
        c->irecvlen = c->irecvlen - reco;              //要收的内容当然要减少,以确保只收到完整的包头先
    } //end  if(reco == m_iLenPkgHeader)
} 

四:遗留问题处理

new出来的内存记得从消息队列中循环释放!

五、测试服务器收包避免推诿扯皮

验证ngx_wait_request_handler()函数是否正常工作,写一个客户端程序,为windows下vs2017的mfc程序,非常简陋,只用于演示目的,不具备商业代码质量,不过SendData()函数值得学习(因为如果服务端收缓冲区可能满)

int SendData(SOCKET sSocket, char *p_sendbuf, int ibuflen)
{
    int usend = ibuflen; //要发送的数目
    int uwrote = 0;      //已发送的数目
    int tmp_sret;
    
    while (uwrote < usend)
    {
        tmp_sret = send(sSocket, p_sendbuf + uwrote, usend - uwrote, 0);
        if ((tmp_sret == SOCKET_ERROR) || (tmp_sret == 0))
        {
            //有错误发生了
            return SOCKET_ERROR;
        }
        uwrote += tmp_sret;
    }//end while
    return uwrote;
}

核心代码文件:MFCApplication3Dlg.cpp(点击确定等属于非正常关闭,写closesocket(sClient)这种代码就是正常关闭)!

 

最后

以上就是舒心眼神为你收集整理的【nginx】网络通讯实战二的全部内容,希望文章能够帮你解决【nginx】网络通讯实战二所遇到的程序开发问题。

如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。

本图文内容来源于网友提供,作为学习参考使用,或来自网络收集整理,版权属于原作者所有。
点赞(43)

评论列表共有 0 条评论

立即
投稿
返回
顶部