套接字和服务端编程原理及设计范式
目录
- 套接字地址结构
- 套接字流程图示
- 一个简单的本地客户和本地服务器
- 一个简单的网络客户和网络服务器
- 主机字节序和网络字节序
- 点分十进制数串和网络字节序二进制IP地址
- 名字与地址转换
- 套接字选项
- 非阻塞式IO
- 常见errno值含义
- 数据报UDP
- 客户/服务器设计
- 服务端设计
- LIBEVENT
- Reactor与Procator的区别
- TCP原理与SOCKET结合
- 经验和问题备忘
- 脚注
- 附件(代码源文件)
Socket编程和服务端编程知识
套接字地址结构
//IPv4套接字地址结构
struct in_addr
{
in_addr_t s_addr; /* 32-bit IPv4 address */
}; /* network byet orered */
stuct sockaddr_in
{
uint8_t sin_len; /* length of structure */
sa_familyt sin_family; /* AF_INET */
in_port_t sin_port; /* 16-bit TCP or UDP port number */
/* network byet orered */
struct in_addr sin_addr; /* 32-bit IPv4 address */
/* network byet orered */
char sin_zero[8] /* unused */
};
//IPv6套接字地址结构
struct in6_addr
{
uint8_t s6_addr[16]; /* 128-bit IPv6 address */
/* network byet orered */
};
#define SIN6_LEN /* required for compile-time tests */
stuct sockaddr_in6
{
uint8_t sin6_len; /* length of this structure (28)*/
sa_family_t sin6_family; /* AF_INET6 */
in_port_t sin6_port; /* transport layer port# */
/* network byet orered */
uint32_t sin6_flowinfo; /* flow information,undefined */
struct in6_addr sin6_addr; /* IPv6 address */
/* network byet orered */
uint32_t sin6_scope_id; /* set of interfaces for a scope */
};
//通用的套接字地址结构
struct sockaddr
{
uint8_t sa_len;
sa_family_t sa_family; /* address family: AF_XXX value */
char sa_data[14]; /* protocol-specific address */
};
//新的通用套接字地址结构
//作为IPv6套接字API的一部分而定义的新的通用套接字地址结构克服了现有stuct sockaddr的 一些缺点。不像stuct sockaddr,新的struct sockaddr_storage足以容纳系统所支持的任何套接字地址结构。该结构在<netinet/in.h>头文件中定义。
struct sockaddr_storage
{
uint8_t ss_len; /* length of this structure (implementation dependent)*/
sa_family_t ss_family; /* address family :AF_xxx value */
/* implementation-dependent elements to provide:
* a) alignment sufficient to fulfill the alignment requirements of
* all socket address types that the system supports.
* b) enough storage to hold any type of socket address that the
* system supports.
*/
};
//UNIX域套接字地址结构
//这个套接字就是通过AF_LOCAL生成的套接字,是单个主机上执行客户/服务器通信的一种方法
#include <sys/un.h>
struct sockaddr_un {
sa_family_t sum_family;// AF_LOCAL
char sun_path[104];//字符串指代路径(null终止)
};
套接字流程图示
一个简单的本地客户和本地服务器
- 路径名作为地址
- 见
localClient.c
和localServer.c
一个简单的网络客户和网络服务器
- 见
netClient.c
和netServer.c
主机字节序和网络字节序
- 不同的计算机使用不同的字节序来表示整数,为了使不同类型的计算机可以就通过网络传输的多字节整数的值达成一致, 你需要定义一个网络字节序。 客户和服务器程序必须在传输之前, 将它们的内部整数表示方式转换为网络字节序。
htonl
,htons
等, 用man手册即可查看详细信息
点分十进制数串和网络字节序二进制IP地址
inet_ntoa, inet_aton
只适用于IPv4, 且返回的指针都会指向同一片静态内存,造成如果调用该函数两次,最终两次的结果都是最后一次的结果。这称为不可重入函数
, 下面的inet_pton和inet_ntop没有这个问题inet_pton, inet_ntop
则IPv4和IPv6均适用,可重入函数
名字与地址转换
- 这里主机名指www.baidu.com类似的主机名
gethostbyname
, 通过主机名得到二进制IPv4地址, 一般不可重入gethostbyaddr
, 通过一个二进制IPv4地址得到主机名,与gethostbyname相反,一般不可重入getserverbyname
, 通过给定服务查找相应服务(服务中包含端口号,端口号已经是网络字节序),例:getservbyname(“ftp”,“tcp”);,一般不可重入getserverbyport
, 根据给定端口号和可选协议查找对应服务,例:getservbyport(htons(21),“udp”);,一般不可重入getaddrinfo
, 能够处理以及服务到端口的转换,且支持IPv4和IPv6。 推荐使用getaddrinfo来替换上述函数,调用之后要调用freeaddrinfo释放内存,可重入的前提是它调用的函数是可重入的,这就是说它应该调用可重入版本的gethostbyname和getservbynamegetnameinfo
, 功能与getaddrinfo相反, 可重入的前提是它调用的函数是可重入的,这就是说它应该调用可重入版本的gethostbyaddr和getservbyport
套接字选项
setsockopt
, 用于设置套接字、IP、TCP等的属性,如设置接收超时时间等,示例如下:
struct timeval tv; tv.tv_sec = 5; tv.tv_usec = 0;
setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
getsockopt
, 获取设置的属性fcntl
, 用于设置各种描述符控制操作(包括套接字的属性,如设置套接字为非阻塞式等)ioctl
在网络编程中功能和fcntl差不多, 利用ioctl可是实现获取主机的mac地址、接口配置(见UNIX网络编程第17章ioctl操作)等,在高级套接字编程中用得多。ioctl(iSocket, FIONREAD, &nread) 获取当前套接字的可读字节数或者说接收缓冲区中标的字节数
非阻塞式IO
- 调用read等函数时如果套接字没有数据可读,立即返回错误。阻塞式套接字调用read等函数时,如果没有数据可读,进程会睡眠,直到有数据可读. 使用socket创建套接字,默认是阻塞的. 非阻塞式套接字可用于实现非阻塞式connect等。详见UNIX网络编程卷1第16章非阻塞式IO.
- 一般搭配select等使用
常见errno值含义
- EAGAIN:非阻塞socket中才会出现,提醒当前操作未完成,可以再尝试一次。当在非阻塞套接字上进行一些阻塞操作,即该操作当前未成功,可能需要等待一段时间可能会成功,则返回EAGAIN.
- EWOULDBLOCK:在VxWorks和Windows上,EAGAIN的名字叫做EWOULDBLOCK
- EINTR:指操作被中断唤醒,需要重新读/写
- ETIMEDOUT: 连接超时
数据报UDP
- UDP调用recvfrom或recv时,读取长度需要设置为最大的报文长度,不用担心会读取超过实际报文长度的报文,UDP是数据报模式,一次只会读取一个报文,即一个客户端一次发送的数据报。 原理如下:UDP是按照接收到的数据次序,一次只读取一个数据报。流模式只要不超过流的容量就可以继续往流上提交数据,另一端只要流上有数据就可以读取,而不管这个数据的开头和结尾;数据报模式,有严格的次序关系和数据报的分割关系。而这两种的不同大概是由于TCP一个套接字只接收来自一个对象的数据,而UDP套接字可以接收来自任意对象的数据。也就是说,只要知道接收端的IP和端口,且网络是可达的,任何主机都可以向接收端发送数据。这时候,如果一次能读取超过一个报文的数据,则会乱套。比如,主机A发送了报文P1,主机B发送了报文P2,如果能够读取超过一个报文的数据,那么就会将P1和P2的数据合并在了一起,这样的数据是没有意义的。
UDP使用connect
- 实际中由于UDP是不可靠的,如果一个服务端发送的数据报丢失或者不启动服务端,将导致客户端永远阻塞于recvfrom,为此需要通过设置超时等方法来解决详见UNIX网络编程8.9节,如果改为使用connect,并将sendto和recvfrom替换为write和send,则在write时会报错
- 对于未连接的套接字,也就是我们常用的的UDP套接字,我们使用的是sendto/recvfrom进行信息的收发,目标主机的IP和端口是在调用sendto/recvfrom时确定的;在一个未连接的UDP套接字上给两个数据报调用sendto函数内核将执行以下六个步骤:1)连接套接字, 2)输出第一个数据报, 3)断开套接字连接4)连接套接字5)输出第二个数据报6)断开套接字连接
对于已连接的UDP套接字,必须先经过connect来向目标服务器进行指定,然后调用read/write进行信息的收发,目标主机的IP和端口是在connect时确定的,也就是说,一旦conenct成功,我们就只能对该主机进行收发信息了。已连接的UDP套接字给两个数据报调用write函数内核将执行以下三个步骤:1)连接套接字2)输出第一个数据报3)输出第二个数据报
由此可以知道,当应用进程知道给同一个目的地址的端口号发送多个数据报时,显示套接字效率更高。 - UDP使用connect函数:见
UDP使用connect.c
和getdate-UDP.c
为UDP增加可靠性
UDP报文丢失问题
- 因为UDP自身的特点,决定了UDP会相对于TCP存在一些难以解决的问题。第一个就是UDP报文缺失问题。
在UDP服务器客户端的例子中,如果客户端发送的数据丢失,服务器会一直等待,直到客户端的合法数据过来。如果服务器的响应在中间被路由丢弃,则客户端会一直阻塞,直到服务器数据过来。防止这样的永久阻塞的方法之一是通过select给客户的recvfrom调用设置一个超时. - 见
增加可靠性UDP_send_recv.c
解决报文乱序问题
- 可通过序列号,序列号使得对端能够对乱序报文正确排序。
- 见
增加可靠性UDP_send_recv.c
UDP流量控制问题
- 众所周知,TCP有滑动窗口进行流量控制和拥塞控制,反观UDP因为其特点无法做到。UDP接收数据时直接将数据放进缓冲区内,如果用户没有及时将缓冲区的内容复制出来放好的话,后面的到来的数据会接着往缓冲区放,当缓冲区满时,后来的到的数据就会覆盖先来的数据而造成数据丢失(因为内核使用的UDP缓冲区是环形缓冲区)。因此,一旦发送方在某个时间点爆发性发送消息,接收方将因为来不及接收而发生信息丢失。解决方法一般采用增大UDP缓冲区,使得接收方的接收能力大于发送方的发送能力。
int n = 220 * 1024; /*220kB*/ setsocketopt(sockfd, SOL_SOCKET, SO_RCVBUF, &n, sizeof(n));
这样我们就把接收方的接收队列扩大了,从而尽量减少丢失数据的发生
多播和广播
- 广播和多播仅应用于UDP,它们对需将报文同时传往多个接收者的应用来说十分重要
- 广播的使用例子包括ARP、DHCP、NTP等,关于多播的实现可以参考
pselect.c
中的广播部分,也可看书 - Setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &on, sizeof(on)); //广播socket
- 多播的使用参考UNIX网络编程21.8节
客户/服务器设计
初级设计范式
- 见
客户&服务器设计范式.zip
, 这个看下就好,已经过时。主要是无非是多线程还是多进程,以及accept是让所有线程进行(低版本linux内核会导致惊群效应),还是通过加锁或由一个线程统一进行。知道下就好,已经过时。 - 还有一种方法,是通过select,见
serverBySelect.c
Reactor设计
- 目前主流的就是Reactor方式,具体有
reactors + thread pool
,reactors in processes
,reactors in threads
.reactors in threads
即陈硕说的one loop per thread, 这里的loop其实就是一个epoll_wait, 即一个检查就绪的循环,而这个是放在一个单独的线程里的,所以叫one loop per thread。 这个的reactors 可以有多个,用于需要不同优先级的情况,比如专门处理心跳的reactors, 或者专门处理高优先级的reactors.reactors + thread pool
可以利用thread pool来高效处理一些计算(多核),降低其它事件的等待时间,因为现在计算是由多个线程来处理。 或者一些需要异步处理的场合,因为可以将任务交给线程池,然后直接返回,处理下一个有效事件。reactors in processes
就是reactors是放在进程中,Nginx的内置方案。如果进程之间不需要交互,这种方式也是不错的选择。
SELECT、POLL、EPOLL
- 内核中实现 select是用轮询方法,即每次检测都会遍历所有FD_SET中的句柄,显然,select函数执行时间与FD_SET中的句柄个数有一个比例关系,即 select要检测的句柄数越多就会越费时。看到这里,您可能要要问了,你为什么不提poll?笔者认为select与poll在内部机制方面并没有太大的差异。相比于select机制,poll只是取消了最大监控文件描述符数限制,并没有从根本上解决select存在的问题。
-
Select Poll Epoll 支持最大连接数 1024(x86) or 2048(x64) 无上限 无上限 IO效率 每次调用进行线性遍历,时间复杂度为O(N) 每次调用进行线性遍历,时间复杂度为O(N) 使用“事件”通知方式,每当fd就绪, 系统注册的回调函数就会被调用, 将就绪fd放到rdllist里面, 这样epoll_wait返回的时候我们就拿到了就绪的fd。时间发复杂度O(1) fd拷贝 每次select都拷贝 每次poll都拷贝 调用epoll_ctl时拷贝进内核并由内核保存,之后每次epoll_wait不拷贝
EPOLL原理和使用
- 要深刻理解epoll,首先得了解epoll的三大关键要素:mmap、红黑树、链表。
- epoll是通过内核与用户空间mmap同一块内存实现的。mmap将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址(不管是用户空间还是内核空间都是虚拟地址,最终要通过地址映射映射到物理地址),使得这块物理内存对内核和对用户均可见,减少用户态和内核态之间的数据交换。内核可以直接看到epoll监听的句柄,效率高。
- 红黑树将存储epoll所监听的套接字。上面mmap出来的内存如何保存epoll所监听的套接字,必然也得有一套数据结构,epoll在实现上采用红黑树去存储所有套接字,当添加或者删除一个套接字时(epoll_ctl),都在红黑树上去处理,红黑树本身插入和删除性能比较好,时间复杂度O(logN)。
- 通过epoll_ctl函数添加进来的事件都会被放在红黑树的某个节点内,所以,重复添加是没有用的。当把事件添加进来的时候时候会完成关键的一步,那就是该事件都会与相应的设备(网卡)驱动程序建立回调关系,当相应的事件发生后,就会调用这个回调函数,该回调函数在内核中被称为:ep_poll_callback,这个回调函数其实就所把这个事件添加到rdllist这个双向链表中。一旦有事件发生,epoll就会将该事件添加到双向链表中。那么当我们调用epoll_wait时,epoll_wait只需要检查rdlist双向链表中是否有存在注册的事件,效率非常可观。这里也需要将发生了的事件复制到用户态内存中即可。
- 如何使用epoll
- 对于读,只要buffer中还有数据就一直读;
- 对于写,只要buffer还有空间且用户请求写的数据还未写完,就一直写。
- ET模式下的accept问题
- 请思考以下一种场景:在某一时刻,有多个连接同时到达,服务器的 TCP 就绪队列瞬间积累多个就绪连接,由于是边缘触发模式,epoll 只会通知一次,accept 只处理一个连接,导致 TCP 就绪队列中剩下的连接都得不到处理。在这种情形下,我们应该如何有效的处理呢?
- 解决的方法是:解决办法是用 while 循环抱住 accept 调用,处理完 TCP 就绪队列中的所有连接后再退出循环。如何知道是否处理完就绪队列中的所有连接呢? accept 返回 -1 并且 errno 设置为 EAGAIN 就表示所有连接都处理完。
- ET模式为什么要设置在非阻塞模式下工作
- 因为ET模式下的读写需要一直读或写直到出错(对于读,当读到的实际字节数小于请求字节数时就可以停止),而如果你的文件描述符如果不是非阻塞的,那这个一直读或一直写势必会在最后一次阻塞。这样就不能在阻塞在epoll_wait上了,造成其他文件描述符的任务饥饿。
- 使用ET和LT的区别
- LT:水平触发,效率会低于ET触发,尤其在大并发,大流量的情况下。但是LT对代码编写要求比较低,不容易出现问题。LT模式服务编写上的表现是:只要有数据没有被获取,内核就不断通知你,因此不用担心事件丢失的情况。
- ET:边缘触发,效率非常高,在并发,大流量的情况下,会比LT少很多epoll的系统调用,因此效率高。但是对编程要求高,需要细致的处理每个请求,否则容易发生丢失事件的情况。
- 代码示例
#include <stdio.h> #include <unistd.h> #include <sys/epoll.h> int main(void) { int epfd,nfds; struct epoll_event ev,events[5]; //ev用于注册事件,数组用于返回要处理的事件 epfd = epoll_create(1); //只需要监听一个描述符——标准输入 ev.data.fd = STDIN_FILENO; ev.events = EPOLLIN|EPOLLET; //监听读状态同时设置ET模式 epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev); //注册epoll事件 for(;;) { /*等待事件触发,当超过timeout还没有事件触发时,就超时。epoll_wait运行的 原理是等侍注册在epfd上的socket_fd的事件的发生,如果发生则将发生的sokct_fd 和事件类型放入到events数组中。并 且将注册在epfd上的socket fd的事件类 型给清空,所以如果下一个循环你还要关注这个socket fd的话,则需要用 epoll_ctl(epfd,EPOLL_CTL_MOD,listenfd,&ev)来重新设置socket fd的事件 类型。这时不用EPOLL_CTL_ADD,因为socket fd并未清空,只是事件类型清空。 这一步非常重要*/ nfds = epoll_wait(epfd, events, 5, -1); //直到注册的时间满足了才返回,不然就沉睡在这里 for(int i = 0; i < nfds; i++) { if(events[i].data.fd==STDIN_FILENO) printf("welcome to epoll's word!\n"); } } }
- 参考链接: - linux epoll详解 和 彻底学会epoll
服务端设计
LIBEVENT
libevent简介
-
Libevent is a library for writing fast portable nonblocking IO
-
IO call is synchronous if, when you call it, it does not return until the operation is completed, or until enough time has passed that your network stack gives up。这就是同步的,libevent是异步的, 虽然Reactor本身依然是同步的.
-
libevent最详细的介绍 总的来说,处理大量连接时,可以采用多线程或者select, 前者当同一时候响应成百上千路的连接请求,不管多线程还是多进程都会严重占领系统资源,减少系统对外界响应效率。 后者应用程序须要消耗大量时间去轮询各个句柄才能发现哪些句柄发生了事件并且该模型将事件探測和事件响应夹杂在一起。一旦事件响应的运行体庞大,则对整个模型是灾难性的。例如事件1的运行体大量耗时将导致响应事件2的运行体迟迟得不到运行。poll和select库。它们的最大的问题就在于效率。它们的处理方式都是创建一个事件列表,然后把这个列表发给内核,返回的时候,再去轮询检查这个列表,这样在描写叙述符比較多的应用中。效率就显得比较低下了。epoll是一种比較好的做法,它把描写叙述符列表交给内核。一旦有事件发生。内核把发生事件的描写叙述符列表通知给进程,这样就避免了轮询整个描写叙述符列表. 不同的操作系统特供的 epoll 接口有非常大差异,所以使用类似于 epoll 的接口实现具有较好跨平台能力的server会比较困难。libevent 库实际上没有更换epoll等的基础。而是使用对于每一个平台最高效的高性能解决方式在实现外加上一个包装器,相当于封装了不同平台的差异性,提供了统一接口。libevent获取所有发生的事件,然后将事件添加到队列中(根据优先级来,优先级最高的先执行,即priority queue)。
-
libevent用的是Reactor模式,工作任务的处理是通过顺序处理的。
-
libevent 状态转化图如下:
Libevent的使用
参考资料
基本用法
- 调用event_base_new()创建一个event_ base对象
- 调用event_new(base, fd, flag, callback, args)创建一个event对象
- 调用event_add(event, NULL)添加event,即添加对fd通知事件的响应
- 调用event_base_dispatch(base)开始事件轮询
bufferevent
- Bufferevent主要是用来管理和调度IO事件。详细请看附件手册和翻译:libevent参考手册第六章:bufferevent:概念和入门 (八)
- base = event_base_new();
- struct bufferevent *bev = bufferevent_socket_new(base, fd, BEV_OPT_CLOSE_ON_FREE); //
- [可选] bufferevent_setwatermark(b1, EV_READ, 128, 0); // 设置读取水位、写入水位。
- bufferevent_setcb(bev, readcb, NULL, eventcb, base);
- bufferevent_enable(bev, EV_READ|EV_WRITE);
- bufferevent_socket_new里的fd替换为-1, 则需要调用bufferevent_socket_connect
- event_base_dispatch(base);
#include <event2/event.h>
#include <event2/bufferevent.h>
#include <sys/socket.h>
#include <string.h>
void eventcb(struct bufferevent *bev, short events, void *ptr)
{
if (events & BEV_EVENT_CONNECTED) {
/* We're connected to 127.0.0.1:8080. Ordinarily we'd do
something here, like start reading or writing. */
} else if (events & BEV_EVENT_ERROR) {
/* An error occured while connecting. */
}
}
int main_loop(void)
{
struct event_base *base;
struct bufferevent *bev;
struct sockaddr_in sin;
base = event_base_new();
memset(&sin, 0, sizeof(sin));
sin.sin_family = AF_INET;
sin.sin_addr.s_addr = htonl(0x7f000001); /* 127.0.0.1 */
sin.sin_port = htons(8080); /* Port 8080 */
/* 这里bufferevent的套接字还没有连接上(值是-1),可以通过bufferevent_socket_connect 实现启动新的连接. 如果已经为bufferevent设置套接字,
调用bufferevent_socket_connect()将告知libevent套接字还未连接,直到连接成功之前不应该对其进行读取或者写入操作。*/
/*创建bufferevent时可以使用一个或者多个标志修改其行为。可识别的标志有: BEV_OPT_CLOSE_ON_FREE:释放bufferevent时关闭底层传输端口。这将关闭底层套接字,释放底层bufferevent等。
BEV_OPT_THREADSAFE:自动为bufferevent分配锁,这样就可以安全地在多个线程中使用bufferevent。BEV_OPT_DEFER_CALLBACKS:设置这个标志时,bufferevent延迟所有回调,如上所述。
BEV_OPT_UNLOCK_CALLBACKS:默认情况下,如果设置bufferevent为线程安全的,则bufferevent会在调用用户提供的回调时进行锁定。设置这个选项会让libevent在执行回调的时候不进行锁定。(BEV_OPT_UNLOCK_CALLBACKS由2.0.5-beta版引入,其他选项由2.0.1-alpha版引入)*/
bev = bufferevent_socket_new(base, -1, BEV_OPT_CLOSE_ON_FREE);
bufferevent_setcb(bev, NULL, NULL, eventcb, NULL);
if (bufferevent_socket_connect(bev,
(struct sockaddr *)&sin, sizeof(sin)) < 0) {
/* Error starting connection */
bufferevent_free(bev);
return -1;
}
event_base_dispatch(base);
return 0;
}
// 另一个程序
#include <event2/dns.h>
#include <event2/bufferevent.h>
#include <event2/buffer.h>
#include <event2/util.h>
#include <event2/event.h>
#include <stdio.h>
void readcb(struct bufferevent *bev, void *ptr)
{
char buf[1024];
int n;
struct evbuffer *input = bufferevent_get_input(bev);
while ((n = evbuffer_remove(input, buf, sizeof(buf))) > 0) {
fwrite(buf, 1, n, stdout);
}
}
void eventcb(struct bufferevent *bev, short events, void *ptr)
{
if (events & BEV_EVENT_CONNECTED) { /*调用了bufferevent_connect_xxx才会出现这个事件*/
printf("Connect okay.\n");
} else if (events & (BEV_EVENT_ERROR|BEV_EVENT_EOF)) {
struct event_base *base = ptr;
if (events & BEV_EVENT_ERROR) {
int err = bufferevent_socket_get_dns_error(bev);
if (err)
printf("DNS error: %s\n", evutil_gai_strerror(err));
}
printf("Closing\n");
bufferevent_free(bev);
event_base_loopexit(base, NULL);
}
}
int main(int argc, char **argv)
{
struct event_base *base;
struct evdns_base *dns_base;
struct bufferevent *bev;
if (argc != 3) {
printf("Trivial HTTP 0.x client\n"
"Syntax: %s [hostname] [resource]\n"
"Example: %s www.google.com /\n",argv[0],argv[0]);
return 1;
}
base = event_base_new();
dns_base = evdns_base_new(base, 1);
bev = bufferevent_socket_new(base, -1, BEV_OPT_CLOSE_ON_FREE);
bufferevent_setcb(bev, readcb, NULL, eventcb, base);
bufferevent_enable(bev, EV_READ|EV_WRITE);
evbuffer_add_printf(bufferevent_get_output(bev), "GET %s\r\n", argv[2]);
bufferevent_socket_connect_hostname(
bev, dns_base, AF_UNSPEC, argv[1], 80);
event_base_dispatch(base);
return 0;
}
- 通用bufferevent操作
- void bufferevent_setwatermark(struct bufferevent *bufev, short events,size_t lowmark, size_t highmark);
- 操作bufferevent中的数据, 如果只是通过网络读取或者写入数据,而不能观察操作过程,是没什么好处的。
这两个函数提供了非常强大的基础:它们分别返回输入和输出缓冲区 struct evbuffer *bufferevent_get_input(struct bufferevent *bufev); struct evbuffer *bufferevent_get_output(struct bufferevent *bufev); 这些函数向bufferevent的输出缓冲区添加数据 int bufferevent_write(struct bufferevent *bufev, const void *data, size_t size); int bufferevent_write_buffer(struct bufferevent *bufev, struct evbuffer *buf); 这些函数从bufferevent的输入缓冲区移除数据。bufferevent_read()至多从输入缓冲区移除size字节的数据,将其存储到内存中data处 size_t bufferevent_read(struct bufferevent *bufev, void *data, size_t size); int bufferevent_read_buffer(struct bufferevent *bufev, struct evbuffer *buf); 对bufferevent发起清空操作接口 int bufferevent_flush(struct bufferevent *bufev, short iotype, enum bufferevent_flush_mode state) 跟其他事件一样,可以要求在一定量的时间已经流逝,而没有成功写入或者读取数据的时候调用一个超时回调。 void bufferevent_set_timeouts(struct bufferevent *bufev, const struct timeval *timeout_read, const struct timeval *timeout_write); 有时候需要确保对bufferevent的一些操作是原子地执行的。为此,libevent提供了手动锁定和解锁bufferevent的函数。 void bufferevent_lock(struct bufferevent *bufev); void bufferevent_unlock(struct bufferevent *bufev);
- 注意事项:
- 使用bufferevent时,每次最多只能读取4096字节。
- bufferevent的可读事件是边沿触发的。也就是说,如果客户端往服务器发了100字节的数据,而且客户端仅仅发送一次数据。那么服务器触发可读事件后,就应该把这100字节都读出来(假设这100字节是一起到达的)。不应该想着,这次回调只读4字节(比如是长度信息),然后等到下次回调再读取其他数据。因为客户端只发送一次数据,所以不会再有下次回调了,即使bufferevent的缓冲区里面还有数据。
- bufferevent线程安全吗?在调用bufferevent_socket_new的时候加入了BEV_OPT_THREADSAFE选项,那么就线程安全了。
- Libevent哪些函数是线程安全的?你认为一个函数理应线程安全,那么Libevent的作者也会认为该函数得是线程安全的。调用evthread_use_pthreads函数后,就放心使用Libevent提供的函数吧。它总会在需要加锁的时候加锁,保证线程安全的。
evbuffer
- 上面讲了Bufferevent主要用于事件的管理和调度IO。而Evbuffer给我们提供了非常实用的IO缓存工具。上一个例子中,虽然解决了断开连接、读取事件等IO管理的工作,但是也是存在缺陷的。1. 因为TCP粘包拆包的原因,我们不知道一次接收到的数据是否是完整的。2. 我们无法根据客户端传递过来的数据来分析客户端的请求信息。根据上面的问题,我们可能会考虑设计一个缓冲容器,这个容器主要用来不停得接收客户端传递过来的数据信息,并且要等到信息量接收到一定的程度的时候,我们对客户端的信息进行分析处理,最后才能知道客户端的请求内容。如果自己做这个缓冲容器,恐怕是需要花费很多的时间,而Libevent已经给我们设计了Evbuffer,我们可以直接使用Evbuffer缓冲容器来满足我们的业务需求。
- [TCP粘包/拆包]. TCP是个“流”协议,所谓流,就是没有界限的一串数据。大家可以想想河里的流水,是连成一片的,其间并没有分界线。TCP底层并不了解上层业务数据的具体含义,它会根据TCP缓冲区的实际情况进行包的划分,所以在业务上认为,一个完整的包可能会被TCP拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送,这就是所谓的TCP粘包和拆包问题
- 示例如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string.h>
#include <fcntl.h>
#include <event2/event.h>
#include <event2/bufferevent.h>
#include <event2/buffer.h>
#define MAX_LINE 256
void read_cb(struct bufferevent *bev, void *arg) {
struct evbuffer *buf = (struct evbuffer *)arg;
char line[MAX_LINE+1];
int n;
evutil_socket_t fd = bufferevent_getfd(bev);
while (n = bufferevent_read(bev, line, MAX_LINE), n > 0) {
line[n] = '\0';
//将读取到的内容放进缓冲区
evbuffer_add(buf, line, n);
//搜索匹配缓冲区中是否有==,==号来分隔每次客户端的请求
const char *x = "==";
struct evbuffer_ptr ptr = evbuffer_search(buf, x, strlen(x), 0);
if (ptr.pos != -1) {
bufferevent_write_buffer(bev, buf); //使用buffer的方式输出结果
}
}
}
void write_cb(struct bufferevent *bev, void *arg) {}
void error_cb(struct bufferevent *bev, short event, void *arg) {
evutil_socket_t fd = bufferevent_getfd(bev);
printf("fd = %u, ", fd);
if (event & BEV_EVENT_TIMEOUT) {
printf("Timed out\n");
} else if (event & BEV_EVENT_EOF) {
printf("connection closed\n");
} else if (event & BEV_EVENT_ERROR) {
printf("some other error\n");
}
//清空缓冲区
struct evbuffer *buf = (struct evbuffer *)arg;
evbuffer_free(buf);
bufferevent_free(bev);
}
//回调函数,用于监听连接进来的客户端socket
void do_accept(evutil_socket_t fd, short event, void *arg) {
int client_socketfd;//客户端套接字
struct sockaddr_in client_addr; //客户端网络地址结构体
int in_size = sizeof(struct sockaddr_in);
//客户端socket
client_socketfd = accept(fd, (struct sockaddr *) &client_addr, &in_size); //等待接受请求,这边是阻塞式的
if (client_socketfd < 0) {
puts("accpet error");
exit(1);
}
//类型转换
struct event_base *base_ev = (struct event_base *) arg;
//socket发送欢迎信息
char * msg = "Welcome to My socket";
int size = send(client_socketfd, msg, strlen(msg), 0);
//创建一个事件,这个事件主要用于监听和读取客户端传递过来的数据
//持久类型,并且将base_ev传递到do_read回调函数中去
//struct event *ev;
//ev = event_new(base_ev, client_socketfd, EV_TIMEOUT|EV_READ|EV_PERSIST, do_read, base_ev);
//event_add(ev, NULL);
//创建一个evbuffer,用来缓冲客户端传递过来的数据
struct evbuffer *buf = evbuffer_new();
//创建一个bufferevent
struct bufferevent *bev = bufferevent_socket_new(base_ev, client_socketfd, BEV_OPT_CLOSE_ON_FREE);
//设置读取方法和error时候的方法,将buf缓冲区当参数传递
bufferevent_setcb(bev, read_cb, NULL, error_cb, buf);
//设置类型
bufferevent_enable(bev, EV_READ|EV_WRITE|EV_PERSIST);
//设置水位
bufferevent_setwatermark(bev, EV_READ, 0, 0);
}
//入口主函数
int main() {
int server_socketfd; //服务端socket
struct sockaddr_in server_addr; //服务器网络地址结构体
memset(&server_addr,0,sizeof(server_addr)); //数据初始化--清零
server_addr.sin_family = AF_INET; //设置为IP通信
server_addr.sin_addr.s_addr = INADDR_ANY;//服务器IP地址--允许连接到所有本地地址上
server_addr.sin_port = htons(8001); //服务器端口号
//创建服务端套接字
server_socketfd = socket(PF_INET,SOCK_STREAM,0);
if (server_socketfd < 0) {
puts("socket error");
return 0;
}
evutil_make_listen_socket_reuseable(server_socketfd); //设置端口重用
evutil_make_socket_nonblocking(server_socketfd); //设置无阻赛
//绑定IP
if (bind(server_socketfd, (struct sockaddr *)&server_addr, sizeof(struct sockaddr))<0) {
puts("bind error");
return 0;
}
//监听,监听队列长度 5
listen(server_socketfd, 10);
//创建event_base 事件的集合,多线程的话 每个线程都要初始化一个event_base
struct event_base *base_ev;
base_ev = event_base_new();
const char *x = event_base_get_method(base_ev); //获取IO多路复用的模型,linux一般为epoll
printf("METHOD:%s\n", x);
//创建一个事件,类型为持久性EV_PERSIST,回调函数为do_accept(主要用于监听连接进来的客户端)
//将base_ev传递到do_accept中的arg参数
struct event *ev;
ev = event_new(base_ev, server_socketfd, EV_TIMEOUT|EV_READ|EV_PERSIST, do_accept, base_ev);
//注册事件,使事件处于 pending的等待状态
event_add(ev, NULL);
//事件循环
event_base_dispatch(base_ev);
//销毁event_base
event_base_free(base_ev);
return 1;
}
常用库函数
- 一些库函数evutil_make_listen_socket_reuseable(server_socketfd); //设置端口重用, evutil_make_socket_nonblocking(server_socketfd); //设置无阻赛, const char *x = event_base_get_method(base_ev); //获取IO多路复用的模型,linux一般为epoll , printf(“METHOD:%s\n”, x);
基于Libevent的HTTP Server
项目中libevent的使用(理解libevent重点看这个)
- 在event2_init中会新建event_base, 个数由形参指定,一般是一个
- 还会创建一个event2_svc线程,其中会循环调用event_base_loop(event_base_loop等待事件被触发,然后调用它们的回调函数,还会根据是不是event_persist决定要不要保留该事件。 等价于epoll_wait+调用回调函数+其它。event_base_loop是 event_base_dispatch的更灵活版本, event_base_dispatch等价于event_base_loop(event_base, 0)) , 这样做的好处时,后续增加事件时就不再需要手动调用处理函数了。
Libevent惊群
- 在多线程情况下,每个线程都监听同一个fd,当有数据到来时, 每个线程都被唤醒, 但是只有一个线程可以读到数据。 具体可参考 libevent多线程验证Linux上的服务器"惊群"现象 , 会发现,回调函数都执行了,但只有一个线程能够通过套接字读取数据。
Reactor与Procator的区别
- 应用程序初始化一个异步读取操作,然后注册相应的事件处理器,此时事件处理器不关注读取就绪事件,而是关注读取完成事件,这是区别于Reactor的关键。
- 在事件分离器等待读取操作完成的时候,操作系统调用内核线程完成读取操作,并将读取的内容放入用户传递过来的缓存区中。这也是区别于Reactor的一点,Proactor中,应用程序需要传递缓存区。事件分离器捕获到读取完成事件后,激活应用程序注册的事件处理器,事件处理器直接从缓存区读取数据,而不需要进行实际的读取操作。异步IO都是操作系统负责将数据读写到应用传递进来的缓冲区供应用程序操作。Proactor中写入操作和读取操作,只不过感兴趣的事件是写入完成事件。
- Proactor有如下缺点:
- 编程复杂性,由于异步操作流程的事件的初始化和事件完成在时间和空间上都是相互分离的,因此开发异步应用程序更加复杂。应用程序还可能因为反向的流控而变得更加难以Debug;
- 内存使用,缓冲区在读或写操作的时间段内必须保持住,可能造成持续的不确定性,并且每个并发操作都要求有独立的缓存,相比Reactor模型,在Socket已经准备好读或写前,是不要求开辟缓存的;
- 操作系统支持,Windows下通过IOCP实现了真正的异步 I/O,而在Linux系统下,Linux2.6才引入,并且异步I/O使用epoll实现的,所以还不完善。
- Reactor模式,响应快,不必为单个同步时间所阻塞,虽然Reactor本身依然是同步的
- 两者另外的区别参见两种高效的事件处理模式Reactor和Proactor
TCP原理与SOCKET结合
基础:
- MTU: 如果 IP层有一个数据报要传,而且数据的长度比链路层的 MTU还大,那么 IP层就需要进行分片(fragmentation),把数据报分成若干片,这样每一片都小于 MTU,MSS+20(TCP头)+20(IP头)就是MTU,MTU的常见大小为1500.
- 窗口大小:实际在三次握手时,双方会把一个叫“Window Scale”的值告知对方,对方收到该值(设为)后作为2的指数(即2^n),并乘以16位的窗口大小来作为实际的窗口,这么做的原因是网络带宽越来越大,65536字节已经不够用了. 发送窗口和MSS的关系:发送窗口决定一次能发多少字节,而MSS决定这些字节分多少个包发送。
- TCP延迟确认: 收到数据包后并不立即确认,而是等待一段时间(绝大多数实现采用的时延为 200 ms), 这时如果有新的数据包到达(或者本身有数据要发往对端)便可一并发送,减少了发送包的次数,节省了带宽, 延迟确认可能会使得四次挥手变成三次挥手,因为延迟确认会省掉四次挥手中的第二个包(ACK和FIN一起发给对端)
- Nagle算法
- 用程序员的表达方式如下:
if(有新数据要发送) if(数据量超过MSS)(即一个TCP包所能携带的最大数据量) 立即发送 else if(之前发出去的数据尚未确认) 把新数据缓存起来,凑够MSS或等确认到达再发送 else 立即发送
- (针对发送频繁交互的数据流)在发送小包(数据字节数少)时,不立即发送,而是在等到之前数据的确认包后或到达最大可传输数据量后才会发送。这里小的含义是,数据量小于MSS则就算小包。
- 使用Nagle算法可以减少这些小报文段的数目,这个算法限制发送者任何时候只能有一个发送的小报文段未被确认。所谓“糊涂窗口综合征”指的就是少量数据通过连接进行交换,而不是满长度(MSS)的报文段
- 有时需要禁止Nagle算法的功能。
- 需要无延时发送数据的情况,比如鼠标移动需要无延时地发送,以便为进行某种操作的交互用户提供实时的反馈
- Nagle算法和延迟确认一起使用可能会产生问题,比如启动Nagle的A端发送a,b,c三个小包给对端,对端接收a后没有数据需要携带返回,由于延迟算法,将等200ms才会发送ACK,A端只有接收到ACK后,才会发送b和c
- 当向对端分多次发送小字节时,应该调用writev发送(将导致TCP输出功能一次而不是多次),或者将多个小字节合并成一个大字节再调用write发送,或者禁用Nagle(下策,可能有损于网络)
- 用程序员的表达方式如下:
- 滑动窗口协议
- 该协议允许发送方在停止并等待确认前可以连续发送多个分组。使得发送方不必每发一个分组就停下来等待确认,因此该协议可以加速数据的传输。慢启动和拥塞避免算法,就是决定一次可以发送几个分组的(即MSS的几倍)
- 每个包的TCP层含有的“window size”,不是发送窗口,而是在向对方声明自己的接收窗口,即最大能接收这么多,跟接收缓冲区中是否有数据有关系,接收缓冲区中最大大小减去缓冲区中已有数据大小。
- TCP的坚持定时器,TCP通过让接收方指明希望从发送方接收的数据字节数(即窗口大小)来进行流量控制。如果窗口大小为 0会发生什么情况呢?这将有效地阻止发送方传送数据,直到窗口变为非0为止。当接收方由于接收数据,导致窗口不为0后,会发送一个窗口更新,即ACK,ACK传输并不可靠,也就是说, TCP不对ACK报文段进行确认, TCP只确认那些包含有数据的ACK报文段。如果一个确认丢失了,则双方就有可能因为等待对方而使连接终止:接收方等待接收数据(因为它已经向发送方通告了一个非 0的窗口),而发送方在等待允许它继续发送数据的窗口更新。为防止这种死锁情况的发生,发送方使用一个坚持定时器 (persist timer)来周期性地向接收方查询,以便发现窗口是否已增大。这些从发送方发出的报文段称为窗口探查 (window probe)。所谓“糊涂窗口综合征”指的就是少量数据通过连接进行交换,而不是满长度(MSS)的报文段,可以避免,方法略。目前知道这个现象就可以了。
- TCP重传 详见《wireshark网络分析就是这么简单》P70-重传的讲究,非常易懂
- 超时重传:一般发生在拥塞之后,发送方收不到确认包,一段时间之后会触发超时重传,超时重传对传输性能影响较大,因为它有一段时间没有传输任何数据,而且拥塞窗口会被设成1个MSS,所以要尽量避免超时重传。
- 快速重传:当发送方收到3个或以上的重复确认时,就认为数据包已经丢失,从而立即重传它。与超时重传相比,它不需要等待一段时间后再发送。由于我们不知道一个重复的ACK是由一个丢失的报文段引起的,还是由于仅仅出现了几个报文段的重新排序,因此我们等待少量重复的 ACK到来。假如这只是一些报文段的重新排序,则在重新排序的报文段被处理并产生一个新的 ACK之前,只可能产生1~2个重复的ACK。 快速重传对性能影响小一些,因为它没有等待时间,而且拥塞窗口的减少的幅度没那么大。
- 丢包对极小文件的影响比大文件严重,因为读写一个小文件需要的包数很少,所以丢包时往往凑不满3个Dup Ack, 只能等待超时重传了。
- SACK告诉对方哪些包收到了,哪些没收到,使得发送方只需要发送没收到的包就好
套接字选项
- 所有的套接字选项,setsockopt是设置,getsockopt就是获取
- SO_LINGER 设置开启后,关闭时立即(或者指定秒数)关闭,而不会进入TIME_WAIT状态,正常的关闭是TCP进入需要等2MSL(时间为1分钟到4分钟之间),SO_LINGER对shutdown无影响
- SO_KEEPALIVE 设置后,如果2小时无数据交换,就发送探测分节。并每隔75秒再发送一个探测分节,共发送n个,如果都没有得到响应,则关闭套接字,项目中的源码如下
HPR_DECLARE HPR_INT32 CALLBACK HPR_SetTCP_KeepAlive
(HPR_SOCK_T iSockFd, HPR_BOOL bYesNo, HPR_UINT32 offLineMmaxTime)
{
int KeepAlive = bYesNo?1:0; /* enable keepalive option! */
/* 对一个连接进行有效性探测之前运行的最大非活跃时间间隔 */
int KeepIdle = (int)offLineMmaxTime;
int KeepInterval = 10;/* 两个探测的时间间隔 */
int KeepCount = 2; /* 关闭一个非活跃连接之前进行探测的最大次数*/
/*检测网线非法断开*/
setsockopt(iSockFd, SOL_SOCKET, SO_KEEPALIVE,(void*)&KeepAlive,sizeof(KeepAlive));
setsockopt(iSockFd, SOL_TCP, TCP_KEEPIDLE, (void*)&KeepIdle,sizeof(KeepIdle));
setsockopt(iSockFd, SOL_TCP, TCP_KEEPINTVL, (void*)&KeepInterval,sizeof(KeepInterval));
setsockopt(iSockFd, SOL_TCP, TCP_KEEPCNT, (void*)&KeepCount,sizeof(KeepCount));
return HPR_OK;
}
- TCP_NODELAY 开启将禁用TCP的Nagle算法
- SO_RCVBUF和SO_SNDBUF,
- 每个套接字都有一个发送缓冲区和一个接收缓冲区,这两个套接字可以改变这两个缓冲区的默认大小。对于客户端,需要在connect之前设置,对于服务端,需要在listen之前设置。接收缓存区大小,就是该连接上能够通告的最大窗口大小。设置大小根据“带宽时延积”来确认,带宽(bits/s),时延RTT(客户到服务器往返所花时间客户到服务器往返所花时间,单位秒),缓冲区大小设为该值最好。
- linux中,接收缓冲区的默认值保存在/proc/sys/net/core/rmem_default,发送缓冲区保存在/proc/sys/net/core/wmem_default , 使用cat /proc/sys/net/core/rmem_default命令即可显示出来
- 通过setsockopt设置SO_SNDBUF、SO_RCVBUF这连个默认缓冲区的值, 设置时是设置成给定值的两倍,为什么要设置成两倍?一种理解是,用户在设置这个值的时候,可能只考虑到数据的大小,没有考虑数据封包的字节开销。所以将这个值设置成两倍,因为在计算机网络的帧结构中,除了有用数据以外,还有很多控制信息,这些控制信息用来保证通信的完成。这些控制信息被称作系统开销。
- SO_REUSEADDR 所有的TCP服务器应该指定该套接字选项,SO_REUSEADDR允许启动一个监听服务器并捆绑其众所周知端口,即使以前建立的将此端口用做他们的本地端口的连接仍存在。这通常是重启监听服务器时出现,若不设置此选项,则bind时将出错(假定之前服务器派生一个子进程来处理一个客户,绑定时那个子进程还在运行,则bind出错)。SO_REUSEADDR允许在同一端口上启动同一服务器的多个实例,只要每个实例捆绑一个不同的本地IP地址即可。对于TCP,我们根本不可能启动捆绑相同IP地址和相同端口号的多个服务器。 SO_REUSEADDR允许单个进程捆绑同一端口到多个套接口上,只要每个捆绑指定不同的本地IP地址即可。这一般不用于TCP服务器。SO_REUSEADDR允许完全重复的捆绑:当一个IP地址和端口绑定到某个套接口上时,还允许此IP地址和端口捆绑到另一个套接口上。一般来说,这个特性仅在支持多播的系统上才有,而且只对UDP套接口而言(TCP不支持多播)。
- SO_RCVLOWAT 和 SO_SNDLOWAT, 接收低水位标志和发送低水位标志,它们有select函数使用,它们是让select返回可读和可写所需的数据量,默认值通常为1和2048
- SO_RECVTIMEO 和 SO_SNDTIMEO 设置套接字的接收和发送设置一个超时值
ioctl(iSocket, FIONREAD, &nread)
; 获取当前套接字的可读字节数或者说接收缓冲区中标的字节数
经验和问题备忘
- recv, send, write, read, recvmsg, sendmsg功能类似
- SO_LINGER对shutdown无影响
- 客户端采用select来检测是否超时,此时如果网线拔掉,select不会返回错误, 可能会返回可读或超时,是否有返回超时情况不确定,但可读情况出现过。
- Tcp是面向连接的。在程序中表现为,当tcp检测到对端socket不再可用时(不能发出探测包,或探测包没有收到ACK的响应包),select会返回socket可读,并且在recv时返回-1,同时置上errno为ETIMEDOUT , 值为110
- 我们可以启动一个客户与服务器建立一个连接,然后离去数小时、数天、数个星期或者数月,而连接依然保持。中间路由器可以崩溃和重启,电话线可以被挂断再连通,但是只要两端的主机没有被重启,则连接可能依然保持建立。
脚注
对于TCP,如果应用进程一直没有读取,接收缓冲区满了之后,发生的动作是:收端通知发端,接收窗口关闭(win=0)。这个便是滑动窗口的实现。保证TCP套接口接收缓冲区不会溢出,从而保证了TCP是可靠传输。因为对方不允许发出超过所通告窗口大小的数据。如果对方无视窗口大小而发出了超过窗口大小的数据,则接收方TCP将丢弃它。, 这就是TCP的流量控制