【学习点滴】linux下网络编程的函数socket、read、io复用

目录

几个函数

socket:

bind

listen,connect

accept

io复用机制

什么情况下用et比较好

muduo


 

几个函数

socket:

socket()用于创建一个socket描述符(socket descriptor),它唯一标识一个socket。这个socket描述字跟文件描述字一样,后续的操作都有用到它,把它作为参数,通过它来进行一些读写操作。

int socket(int domain, int type, int protocol);

domain:即协议域,又称为协议族(family)。常用的协议族有,AF_INET(IPv4)、AF_INET6(IPv6)、AF_LOCAL(或称AF_UNIX,Unix域socket)、AF_ROUTE等等。协议族决定了socket的地址类型,在通信中必须采用对应的地址,如AF_INET决定了要用ipv4地址(32位的)与端口号(16位的)的组合、AF_UNIX决定了要用一个绝对路径名作为地址。 
type:指定socket类型。常用的socket类型有,SOCK_STREAM(流式套接字)、SOCK_DGRAM(数据报式套接字)、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等等 
protocol:就是指定协议。常用的协议有,IPPROTO_TCP、PPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,它们分别对应TCP传输协议、UDP传输协议、STCP传输协议、TIPC传输协议。

注意:并不是上面的type和protocol可以随意组合的,如SOCK_STREAM不可以跟IPPROTO_UDP组合。当protocol为0时,会自动选择type类型对应的默认协议。

 

当我们调用socket创建一个socket时,返回的socket描述字它存在于协议族(address family,AF_XXX)空间中,但没有一个具体的地址。如果想要给它赋值一个地址,就必须调用bind()函数,否则就当调用connect()、listen()时系统会自动随机分配一个端口。

sockaddr_un
进程间通信的一种方式是使用UNIX套接字,人们在使用这种方式时往往用的不是网络套接字,而是一种称为本地套接字的方式。这样做可以避免为黑客留下后门。

创建
使用套接字函数socket创建,不过传递的参数与网络套接字不同。域参数应该是PF_LOCAL或者PF_UNIX,而不能用PF_INET之类。本地套接字的通讯类型应该是SOCK_STREAM或SOCK_DGRAM,协议为默认协议。例如:
 int sockfd;
 sockfd = socket(PF_LOCAL, SOCK_STREAM, 0);

绑定
创建了套接字后,还必须进行绑定才能使用。不同于网络套接字的绑定,本地套接字的绑定的是struct sockaddr_un结构。struct sockaddr_un结构有两个参数:sun_family、sun_path。sun_family只能是AF_LOCAL或AF_UNIX,而sun_path是本地文件的路径。通常将文件放在/tmp目录下。例如:

 struct sockaddr_un sun;
 sun.sun_family = AF_LOCAL;
 strcpy(sun.sun_path, filepath);
 bind(sockfd, (struct sockaddr*)&sun, sizeof(sun));

监听
本地套接字的监听、接受连接操作与网络套接字类似。

连接
连接到一个正在监听的套接字之前,同样需要填充struct sockaddr_un结构,然后调用connect函数。

连接建立成功后,我们就可以像使用网络套接字一样进行发送和接受操作了。甚至还可以将连接设置为非阻塞模式,这里就不赘述了。


 

 

 

bind

bind()函数把一个地址族中的特定地址赋给socket。例如对应AF_INET、AF_INET6就是把一个ipv4或ipv6地址和端口号组合赋给socket。

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

函数的三个参数分别为: 
sockfd:即socket描述字,它是通过socket()函数创建了,唯一标识一个socket。bind()函数就是将给这个描述字绑定一个名字。 
addr:一个const struct sockaddr *指针,指向要绑定给sockfd的协议地址。

addrlen:对应的是地址的长度。 

其中,addr参数

struct sockaddr_in {
    sa_family_t    sin_family; /* address family: AF_INET */
    in_port_t      sin_port;   /* port in network byte order */
    struct in_addr sin_addr;   /* internet address */
};
/* Internet address. */
struct in_addr {
    uint32_t       s_addr;     /* address in network byte order */
};



	//服务器IP+PORT					用于绑定端口使用
	struct sockaddr_in serverAddr;
	serverAddr.sin_family = PF_INET;				//选择协议
	serverAddr.sin_port = htons(SERVER_PORT);			//选择端口,此处宏定义为8888
	serverAddr.sin_addr.s_addr = inet_addr(SERVER_IP);		//选择ip,宏定义为"127.0.0.1"

htons将主机的无符号短整形数转换成网络字节顺序 
htonl将主机的无符号长整形数转换成网络字节顺序

inet_addr()的功能是将一个点分十进制的IP转换成一个长整数型数(u_long类型)

如ipAddr.S_un.S_addr = inet_addr("127.0.0.1"); //将字符串形式的IP地址转换为按网络字节顺序的整型值

htonl,其实是host to network, l 的意思是返回类型是long

htons,其实是host to network, s 的意思是返回类型是short

ntohs 以及 ntohl  同理

 

 


    通常服务器在启动的时候都会绑定一个众所周知的地址(如ip地址+端口号),用于提供服务,客户就可以通过它来接连服务器;而客户端就不用指定,有系统自动分配一个端口号和自身的ip地址组合。这就是为什么通常服务器端在listen之前会调用bind(),而客户端就不会调用,而是在connect()时由系统随机生成一个。

在实际网络编程中,往往会设置成 serverAddr.sin_addr.s_addr =INADDR_ANY; 这是因为服务器主机可能有多个网卡即多个IP地址,设为这个就能保证对此服务器上任意网卡的请求都能被本socket fd监听到。

 

 

listen,connect

如果作为一个服务器,在调用socket()、bind()之后就会调用listen()来监听这个socket,如果客户端这时调用connect()发出连接请求,服务器端就会接收到这个请求。

int listen(int sockfd, int backlog);
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

listen函数的第一个参数即为要监听的socket描述字,第二个参数为相应socket可以排队的最大连接个数。socket()函数创建的socket默认是一个主动类型的,listen函数将socket变为被动类型的,等待客户的连接请求。

connect函数的第一个参数即为客户端的socket描述字,第二参数为服务器的socket地址,第三个参数为socket地址的长度。客户端通过调用connect函数来建立与TCP服务器的连接。

listen函数将主动套接字转换为被动监控套接字,其第二个参数backlog决定了内核的连接缓存队列长度。对于一个给定的监听套接字,内核维护两个队列:

① 未就绪队列,存放没有完成三路握手的连接,监听套接字收到SYN并返回ACK+SYN,连接处于SYN_RECV状态,等待对端发送ACK。如果已完成队列非满,则接收ACK,连接握手完成,进入已完成队列;如果已完成队列满则丢弃ACK,对端重发ACK(对端看到的连接是ESTABLISED状态),若未就绪队列中的SYN_RECV等待直到超时还没进入已完成队列则丢弃连接(对端不知道,只有在读写套接字时才知道)。

② 已完成队列,存放已经完成三路握手的连接(ESTABLISHED),等待accept取走连接。

backlog决定了两个队列的长度之和(并不是说两个队列之和等于backlog,而是存在个转换,依赖于具体实现)。

如果未就绪队列满则忽略新到来的SYN请求,对端重发,如果一直不能进入未就绪队列则对端connect失败返回。

当监听套接字关闭时:① 会对已完成队列中的每个连接发送复位分节RST,对端捕获RST被动关闭连接;② 直接释放未就绪队列的连接,这时对端不知道,对端的连接状态依然保持ESTABLISHED状态,直到对端主动关闭连接,由于监听端已经关闭连接,所以以RST响应对端的FIN,对端收到RST直接关闭连接。(类似于半打开连接)

 

 

accept

TCP服务器端依次调用socket()、bind()、listen()之后,就会监听指定的socket地址了。TCP客户端依次调用socket()、connect()之后就向TCP服务器发送了一个连接请求。TCP服务器监听到这个请求之后,就从已完成连接队列中取出一个socket,调用accept()函数取接收请求,这样连接就建立好了。之后就可以开始网络I/O操作了,即类同于普通文件的读写I/O操作。

其中:

      listen

          在server这端,准备了一个未完成的连接队列,保存只收到SYN_C的socket结构;这些套接口处于 SYN_RCVD 状态,

          还准备了已完成的连接队列,即保存了收到了最后一个ACK的socket结构。这些套接口处于 ESTABLISHED 状态。

          这里需要注意的是,listen()函数不会阻塞,它主要做的事情为,将该套接字和套接字对应的连接队列长度告诉 Linux 内核,然后,listen()函数就结束。

      accept

         应用进程调用accept的时候,就是去检查上面说的已完成的连接队列,如果队列里有连接,就返回一个可以读写的连接

         如果没有,即空的,blocking方试调用,就睡眠等待;

                                         nonblocking方式调用,就直接返回,一般一"EWOULDBLOCK“ errno告诉调用者,连接队列是空的。 
 

函数原型:

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

accept函数的第一个参数为服务器的socket描述字,第二个参数为指向struct sockaddr *的指针,用于返回客户端的协议地址,第三个参数为客户端协议地址的长度。如果accpet成功,那么其返回值是由内核自动生成的一个全新的描述字,代表与返回客户的TCP连接。

注意:accept的第一个参数为服务器的socket描述字,是服务器开始调用socket()函数生成的,称为监听socket描述字;而accept函数返回的是已连接的socket描述字。一个服务器通常通常仅仅只创建一个监听socket描述字,它在该服务器的生命周期内一直存在。内核为每个由服务器进程接受的客户连接创建了一个已连接socket描述字,当服务器完成了对某个客户的服务,相应的已连接socket描述字就被关闭。

其中struct sockaddr *addr    为

struct sockaddr_in client_address;    //用来保存自动创建的已连接客户端的socket的ip和端口
socklen_t client_addrLength = sizeof(struct sockaddr_in);
int clientfd = accept(listener, (struct sockaddr*)&client_address, &client_addrLength);

 

 

recv和send

ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

recv:

    第一个参数指定接收端套接字描述符;

    第二个参数指明一个缓冲区,该缓冲区用来存放recv函数接收到的数据;

    第三个参数指明buf的长度;

    第四个参数一般置0。(man里面说了,flag=0的话此行为与read行为大致相同)

(1)recv先等待s的发送缓冲中的数据被协议传送完毕,如果协议在传送s的发送缓冲中的数据时出现网络错误,那么recv函数返回SOCKET_ERROR,

(2)如果s的发送缓冲中没有数据或者数据被协议成功发送完毕后,recv先检查套接字s的接收缓冲区,如果s接收缓冲区中没有数据或者协议正在接收数据,那么recv就一直等待,直到协议把数据接收完毕。当协议把数据接收完毕,recv函数就把s的接收缓冲中的数据copy到buf中(注意协议接收到的数据可能大于buf的长度,所以 在这种情况下要调用几次recv函数才能把s的接收缓冲中的数据copy完。recv函数仅仅是copy数据,真正的接收数据是协议来完成的),recv函数返回其实际copy的字节数。如果recv在copy时出错,那么它返回SOCKET_ERROR;如果recv函数在等待协议接收数据时网络中断了,那么它返回0。

注意:在Unix系统下,如果recv函数在等待协议接收数据时网络断开了,那么调用recv的进程会接收到一个SIGPIPE信号,进程对该信号的默认处理是进程终止。

 

 

首先阻塞接收的recv有时候会返回0,这仅在对端已经关闭TCP连接时才会发生。

而当拔掉设备网线的时候,recv并不会发生变化,仍然阻塞,如果在这个拔网线阶段,socket被关掉了,后果可能就是recv永久的阻塞了。所以一般对于阻塞的socket都会用setsockopt来设置recv超时,当超时时间到达后,recv会返回错误,也就是-1,而此时的错误码是EAGAIN或者EWOULDBLOCK,POSIX.1-2001上允许两个任意一个出现都行,所以建议在判断错误码上两个都写上。

      如果socket是被对方用linger为0的形式关掉,也就是直接发RST的方式关闭的时候,recv也会返回错误,错误码是ECONNREST
 

一般设置超时的阻塞recv常用的方法都如下:

Linux环境下,须如下定义:struct timeval timeout = {3,0}; 
//设置发送超时
setsockopt(socket,SOL_SOCKET,SO_SNDTIMEO,(char *)&timeout,sizeof(struct timeval));

//设置接收超时
setsockopt(socket,SOL_SOCKET,SO_RCVTIMEO,(char *)&timeout,sizeof(struct timeval));
 

阻塞与非阻塞recv返回值没有区分,都是

 >  0  成功接收数据大小。

 =  0  另外一端关闭了套接字

 = -1     错误,需要获取错误码errno(win下是通过WSAGetLastError())

 errno被设为以下的某个值:

EAGAIN:在套接字已标记为非阻塞情况下,接收操作出现阻塞或者接收超时

               对非阻塞socket而言,EAGAIN不是一种错误。在VxWorks和Windows上,EAGAIN的名字叫EWOULDBLOCK。
EBADF:sock不是有效的描述符

ECONNREFUSE:远程主机拒绝网络连接

EFAULT:内存空间访问出错
EINTR:操作被信号中断
EINVAL:参数无效
ENOMEM:内存不足
ENOTCONN:与面向连接关联的套接字尚未被连接上
ENOTSOCK:sock索引的不是套接字 
返回值<0时并且(errno == EINTR || errno == EWOULDBLOCK || errno == EAGAIN)的情况下认为连接是正常的,继续接收。
只是阻塞模式下recv会阻塞着接收数据,非阻塞模式下如果没有数据会返回,不会阻塞着读,因此需要循环读取)。
 

 

这里只描述同步Socket的send函数的执行流程。当调用该函数时,

(1)send先比较待发送数据的长度len和套接字s的发送缓冲的长度, 如果len大于s的发送缓冲区的长度,该函数返回SOCKET_ERROR; 
(2)如果len小于或者等于s的发送缓冲区的长度,那么send先检查协议s的发送缓冲中的数据是否正在发送,如果是就等待协议把数据发送完,如果协议还没有开始发送s的发送缓冲中的数据或者s的发送缓冲中没有数据,那么send就比较s的发送缓冲区的剩余空间和len 
(3)如果len大于剩余空间大小,send就一直等待协议把s的发送缓冲中的数据发送完 
(4)如果len小于剩余 空间大小,send就仅仅把buf中的数据copy到剩余空间里(注意并不是send把s的发送缓冲中的数据传到连接的另一端的,而是协议传的,send仅仅是把buf中的数据copy到s的发送缓冲区的剩余空间里)。

如果send函数copy数据成功,就返回实际copy的字节数,如果send在copy数据时出现错误,那么send就返回SOCKET_ERROR;如果send在等待协议传送数据时网络断开的话,那么send函数也返回SOCKET_ERROR。

注意:send函数把buf中的数据成功copy到s的发送缓冲的剩余空间里后它就返回了,但是此时这些数据并不一定马上被传到连接的另一端。如果协议在后续的传送过程中出现网络错误的话,那么下一个socket函数就会返回SOCKET_ERROR。(每一个除send外的socket函数在执 行的最开始总要先等待套接字的发送缓冲中的数据被协议传送完毕才能继续,如果在等待时出现网络错误,那么该Socket函数就返回 SOCKET_ERROR)

注意:在Unix系统下,如果send在等待协议传送数据时网络断开的话,调用send的进程会接收到一个SIGPIPE信号,进程对该信号的默认处理是进程终止。

 

io复用机制

1 select的低效率

  select/poll函数效率比较低,主要有以下两个原因:

  (1)调用select函数后需要对所有文件描述符进行循环查找

  (2)每次调用select函数时都需要向该函数传递监视对象信息

  在这两个原因中,第二个原因是主要原因:每次调用select函数时,应用程序都要将所有文件描述符传递给操作系统,这给程序带来很大的负担。在高并发的环境下,无论怎样优化应用程序的代码,都无法完成应用的服务。  

  所以,select与poll并不适合以Web服务器端开发为主流的现代开发环境,只在要求满足以下两个条件是适用:

  (1)服务器端接入者少

  (2)程序要求兼容性

2 Linux的epoll机制

  由上一节,我们需要一种类似于select的机制来完成高并发的服务器。需要有以下两个特点(epoll和select的区别)

  (1)应用程序仅向操作系统传递1次监视对象

  (2)监视范围或内容发生变化是,操作系统只通知发生变化的事项给应用程序

  幸运的是,的确存在这样的机制。Linux的支持方式是epoll,Windows的支持方式是IOCP。

3 epoll函数原型  

  epoll操作由三个函数组成:  

#include <sys/epoll.h>
int epoll_create(int size);
            //成功时返回epoll文件描述符,失败时返回-1
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
            //成功时返回0,失败时返回-1
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
            //成功时返回发生事件的文件描述数,失败时返回-1

(1)epoll_create:创建保存epoll文件描述符的空间,即在内核中创建事件表。

  调用epoll_create函数时创建的文件描述符保存空间称为“epoll例程”。但要注意:size参数只是应用程序向操作系统提的建议,操作系统并不一定会生成一个大小为size的epoll例程。

      epoll在内核初始化的时候向内核注册了一个文件系统,用于存储上述被监控的socket,同时还会开辟出epoll自己的内核高速cache区,用于安置需要监控的fd。这些fd以红黑树的形式保存在内核cache里,以支持快速的查找、插入、删除。这个内核高速cache区,就是建立连续的物理内存页,然后在之上建立slab层,简单的说就是物理上分配好你想要的大小的内存对象,每次使用时都是使用空闲的已分配好的对象。
 

(2)epoll_ctl:向空间(事件红黑树上)注册或者注销文件描述符

  参数epfd指定注册监视对象的epoll例程的文件描述符,op指定监视对象的添加、删除或更改等操作,有以下两种常量:

    1)EPOLL_CTL_ADD:将文件描述符注册到epoll例程

    2)EPOLL_CTL_DEL:从epoll例程中删除文件描述符

    3)EPOLL_CTL_MOD:更改注册的文件描述符的关注事件发生情况

  fd指定需要注册的监视对象文件描述符,event指定监视对象的事件类型。epoll_event结构体如下:

struct epoll_event
{
      __uint32_t events;
      epoll_data_t data;            
}
typedef union epoll_data
{
      void *ptr;
      int fd;
      __uint32_t u32;
      __uint64_t u64;    
}epoll_data_t;

epoll_event的成员events中可以保存的常量及所指的事件类型有以下:

    1)EPOLLIN:需要读取数据的情况(包括对端SOCKET正常关闭)

    2) EPOLLOUT:输出缓冲为空,可以立即发送数据的情况 (表示对应的文件描述符可以写) 

    3) EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有外带数据到来)

    4) EPOLLRDHUP:断开连接或半关闭的情况,这在边缘触发方式下非常有用

    5) EPOLLERR:发生错误的情况

              6)EPOLLHUP:表示对应的文件描述符被挂断(收到RST分节)

    7) EPOLLET:以边缘触发的方式得到事件通知,因为默认为水平触发

                                    (ET模式拷贝完活跃事件后event【epitem中存放的)不放回就绪队列)

    8) EPOLLONESHOT:发生一次事件后,相应文件描述符不再收到事件通知。因此需要向epoll_ctl函数的第二个参数EPOLL_CTL_MOD,再次设置事件。注意,如果epitem被设置为EPOLLONESHOT模式,则当这个epitem上的事件拷贝到用户空间之后,会将这个epitem上的关注事件清空(只是关注事件被清空,并没有从epoll中删除,要删除必须对那个描述符调用EPOLL_DEL),也就是说即使这个epitem上有触发事件,但是因为没有用户关注的事件所以不会被重新添加到readylist中.

(3)epoll_wait:与select函数类似,等待文件描述符发生变化。操作系统返回epoll_event类型的结构体通知监视对象的变化。timeout函数是为毫秒为单位的等待时间,传递-1时,一直等待直到事件发生。声明足够大的epoll_event结构体数组后,传递给epoll_wait函数时,发生变化的文件符信息将被填入该数组。因此,不需要像select函数那样针对所有文件符进行循环。参数events用来从内核得到事件的集合maxevents告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。

enum EPOLL_EVENTS
  {
    EPOLLIN = 0x001,

    EPOLLPRI = 0x002,

    EPOLLOUT = 0x004,

    EPOLLRDNORM = 0x040,

    EPOLLRDBAND = 0x080,

    EPOLLWRNORM = 0x100,

    EPOLLWRBAND = 0x200,

    EPOLLMSG = 0x400,

    EPOLLERR = 0x008,

    EPOLLHUP = 0x010,

    EPOLLRDHUP = 0x2000,

    EPOLLWAKEUP = 1u << 29,

    EPOLLONESHOT = 1u << 30,

    EPOLLET = 1u << 31

  };

 

 

5 水平触发与边缘触发

  水平触发:只要引起epoll_wait返回的事件还存在,再次调用epoll_wait时,该事件还会被注册

  边缘触发:每个事件在刚发生的时候被注册一次,之后就不会被注册,除非又有新的事件发生。

  比如,一个已连接的socket套接字收到了数据,而读取缓冲区小于接收到的数据,这时,两种触发方式有以下区别:(1)水平触发:一次读取之后,套接字缓冲区里还有数据,再调用epoll_wait,该套接字的EPOLL_IN事件还是会被注册;(2)边缘触发:一次读取之后,套接字缓冲区里还有数据,再调用epoll_wait,该套接字的EPOLL_IN事件不会被注册,除非在这期间,该套接字收到了新的数据。

  epoll默认采用水平触发。

 for (int i = 0; i < event_cnt; ++i)
        {
            if (ep_events[i].data.fd == listenfd)
            {
                connfd = accept(listenfd, NULL, NULL);
                //设置为非阻塞I/O
                int flag = fcntl(fd, F_GETFL, 0);
                fcntl(fd, F_SETFL, flag | O_NONBLOCK);

                event.events = EPOLLIN|EPOLLET;       //边缘触发
                event.data.fd = connfd;
                epoll_ctl(pefd, EPOLL_CTL_ADD, connfd, &event);
                printf("connect another client\n");
            }
            else
            {
                //读完每个已连接socket的缓冲区里的数据
                while (1)
                {
                    int nread = read(ep_events[i].dada.fd, buf, BUF_SIZE);
                    if (nread == 0)
                    {
                        close(ep_events.data.fd);
                        epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events.data.fd, NULL);
                        printf("disconnect with a client\n");
                    }
                    else if (nread < 0)
                    {
                        //errno为EAGAIN,则缓冲区内已没有数据
                        if (errno == EAGAIN)
                            break;
                    }
                    else
                    {
                        write(ep_events[i].data.fd, buf, nread);
                    }
                }
            }

几个说明:

  (1)在使用epoll_ctl注册事件的时候,选择边缘触发,|EPOLLET

  (2)处理已发生的边缘触发的事件时,要处理完所有的数据再返回。例中,使用了循环的方式读取了套接字中的所有数据

  (3)读/写套接字的时候采用非阻塞式I/O。为何?边缘触发方式下,以阻塞方式工作的read&write函数有可能引起服务器端的长时间停顿。

  那么边缘触发好不好?有什么优点呢?书上说,边缘触发可以分离接收数据和处理数据的时间点。也就是说,在事件发生的时候,我们只记录事件已经发生,而不去处理数据,等到以后的某段时间才去处理数据,即分离接收数据和处理数据的时间点。好奇的我一定会问:条件触发没办法分离接收数据和处理数据的时间点吗?答案是可以的。但存在问题:在数据被处理之前,每次调用epoll_wait都会产生相应的事件,在一个具有大量这样的事件的繁忙服务器上,这是不现实的。

  可是。还没有说边缘触发和条件触发哪个更好呀?马克思说,要辩证地看问题。so,边缘触发更有可能带来高性能,但不能简单地认为“只要使用边缘触发就一定能提高速度”,要具体问题具体分析。好吧,马克思的这一个“具体问题具体分析”适用于回答绝大部分比较类问题,已和“多喝水”,“重启一下试试看”,“不行就分”并列成为最简单粗暴的4个通用回答。

 

fcntl(listenfd, F_SETFL, O_NONBLOCK);

fcntl(sockfd, F_SETFL, fcntl(sockfd, F_GETFD, 0) | O_NONBLOCK);将套接字设定为非阻塞

总结:

1.水平触发LT时socket为非阻塞或者阻塞都可以,因此就算这次没读完,下次还会触发此事件

2.边缘触发ET时一定要设置socket为非阻塞,因为这次没读完,下次就不会触发了,应该对recv和send套上一个while循环,直到recv返回值为-1且error==EAGAIN时表示读到不可读。(或者说最后一次读到的recv返回值小于指明buf的长度就表示已经读完了)。对于写操作,一直写到返回值为-1且errno==EAGAIN时表示写完了。

            if (events[i].events & EPOLLIN) {
                n = 0;
                while ((nread = read(fd, buf + n, BUFSIZ-1)) > 0) {//ET下可以读就一直读
                    n += nread;
                }
                if (nread == -1 && errno != EAGAIN) {
                    perror("read error");
                }
            if (events[i].events & EPOLLOUT) {
              sprintf(buf, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\nHello World", 11);
                int nwrite, data_size = strlen(buf);
                n = data_size;
                while (n > 0) {
                    nwrite = write(fd, buf + data_size - n, n);//ET下一直将要写数据写完
                    if (nwrite < n) {
                        if (nwrite == -1 && errno != EAGAIN) {
                            perror("write error");
                        }
                        break;
                    }
                    n -= nwrite;
                }
                close(fd);
            }

输入输出缓冲区,系统会为每个socket都单独分配,并且是在socket创建的时候自动生成的。一般来说,默认的输入输出缓冲区大小为8K=8*1024字节。套接字关闭的时候,输出缓冲区的数据不会丢失,会由协议发送到另一方;而输入缓冲区的数据则会丢失。

ET和LT的区别?源码层面:

/* 该函数作为callbakc在ep_scan_ready_list()中被调用
 * head是一个链表, 包含了已经ready的epitem,
 * 这个不是eventpoll里面的ready list, 而是上面函数中的txlist.
 */
static int ep_send_events_proc(struct eventpoll *ep, struct list_head *head,
                   void *priv)
{
    struct ep_send_events_data *esed = priv;
    int eventcnt;
    unsigned int revents;
    struct epitem *epi;
    struct epoll_event __user *uevent;

    /* 扫描整个链表... */
    for (eventcnt = 0, uevent = esed->events;
         !list_empty(head) && eventcnt < esed->maxevents;) {
        /* 取出第一个成员 */
        epi = list_first_entry(head, struct epitem, rdllink);
        /* 然后从链表里面移除 */
        list_del_init(&epi->rdllink);
        /* 读取events, 
         * 注意events我们ep_poll_callback()里面已经取过一次了, 为啥还要再取?
         * 1. 我们当然希望能拿到此刻的最新数据, events是会变的~
         * 2. 不是所有的poll实现, 都通过等待队列传递了events, 有可能某些驱动压根没传
         * 必须主动去读取. */
        revents = epi->ffd.file->f_op->poll(epi->ffd.file, NULL) &
            epi->event.events;
        if (revents) {
            /* 将当前的事件和用户传入的数据都copy给用户空间,
             * 就是epoll_wait()后应用程序能读到的那一堆数据. */
            if (__put_user(revents, &uevent->events) ||
                __put_user(epi->event.data, &uevent->data)) {
                list_add(&epi->rdllink, head);
                return eventcnt ? eventcnt : -EFAULT;
            }
            eventcnt++;
            uevent++;
            if (epi->event.events & EPOLLONESHOT)
                epi->event.events &= EP_PRIVATE_BITS;
            else if (!(epi->event.events & EPOLLET)) {
                /* 嘿嘿, EPOLLET和非ET的区别就在这一步之差呀~
                 * 如果是ET, epitem是不会再进入到readly list,
                 * 除非fd再次发生了状态改变, ep_poll_callback被调用.
                 * 如果是非ET, 不管你还有没有有效的事件或者数据,
                 * 都会被重新插入到ready list, 再下一次epoll_wait
                 * 时, 会立即返回, 并通知给用户空间. 当然如果这个
                 * 被监听的fds确实没事件也没数据了, epoll_wait会返回一个0,
                 * 空转一次.
                 */
                list_add_tail(&epi->rdllink, &ep->rdllist);
            }
        }
    }
    return eventcnt;
}

就是LT模式下,会把就绪链表取出来的epi再放回尾部,下次再wait到再判断还有没有可读写的事件。

 

什么情况下用et比较好

作者:戈君
链接:https://www.zhihu.com/question/20502870/answer/142303523

    在eventloop类型(包括各类fiber/coroutine)的程序中, 处理逻辑和epoll_wait都在一个线程, ET相比LT没有太大的差别. 反而由于LT醒的更频繁, 可能时效性更好些. 在老式的多线程RPC实现中, 消息的读取分割和epoll_wait在同一个线程中运行, 类似上面的原因, ET和LT的区别不大.
    但在更高并发的RPC实现中, 为了对大消息的反序列化也可以并行, 消息的读取和分割可能运行和epoll_wait不同的线程中, 这时ET是必须的, 否则在读完数据前, epoll_wait会不停地无谓醒来.

 

看到官方文档中:

Q9:  Do I need to continuously read/write a file descriptor until EAGAIN when using the EPOLLET flag (edge-triggered behavior) ?

A9:  Receiving an event from epoll_wait(2) should suggest to you that such file descriptor is ready for  the  requested  I/O  operation. You must consider it ready until the next (nonblocking) read/write yields EAGAIN.  When and how you will use the file descriptor is entirely up to you.

   For packet/token-oriented files (e.g., datagram socket, terminal in canonical mode),  the  only  way  to  detect  the  end  of  the read/write I/O space is to continue to read/write until EAGAIN.

   For  stream-oriented  files (e.g., pipe, FIFO, stream socket), the condition that the read/write I/O space is exhausted can also be detected by checking the amount of data read from / written to the target file descriptor.  For example, if  you  call  read(2)  by asking  to  read a certain amount of data and read(2) returns a lower number of bytes, you can be sure of having exhausted the read  I/O space for the file descriptor.  The same is true when writing using write(2).  (Avoid this latter technique if you cannot guarantee that the monitored file descriptor always refers to a stream-oriented file.)

    如同上文提到的,使用et模式,某个fd每次有事件发生时我们不比将其读到EAGAIN才停止,而可以根据我们自己订的协议,读出缓冲区的一部分内容进行处理(放到另一个线程中处理),剩下一部分内容下一次再来读。(eventloop类型的程序中,使用LT模式的话,本线程就会不断地从epoll_wait中返回,占满cpu)。


EPOLLONESHOT

官方文档中有这样一段话:
    Since  even  with  edge-triggered  epoll,  multiple events can be generated upon receipt of multiple chunks of data, the caller has the option to specify the EPOLLONESHOT flag, to tell epoll to disable the associated file descriptor after the receipt  of  an  event  with epoll_wait(2).   When  the  EPOLLONESHOT  flag  is  specified,  it  is  the  caller's responsibility to rearm the file descriptor using epoll_ctl(2) with EPOLL_CTL_MOD.

    即使使用边缘触发的epoll,也可以在接收到多个数据块时产生多个事件,因此调用者可以选择指定EPOLLONESHOT标志,告诉epoll在接收到epoll_wait(2)事件后禁用相关的文件描述符。当EPOLLONESHOT标记被指定时,调用者的责任是使用epoll_ctl(2)和EPOLL_CTL_MOD重新配置文件描述符。

 

  • Possible pitfalls and ways to avoid them

1. Starvation (edge-triggered)

    如果某fd有大量的I/O操作或是计算,那么此线程将耗费大量时间处理此fd,其他fd可能不会得到处理,从而导致饥饿现象。(这个问题不是epoll特有的。)

    解决方案是维护一个就绪列表,并在其关联的数据结构中将文件描述符标记为ready,从而允许应用程序记住需要处理哪些文件,但仍然在所有就绪文件之间进行轮询。这还支持忽略已经准备好的文件描述符接收到的后续事件。

2. If using an event cache

If you use an event cache or store all the file descriptors returned from epoll_wait(2), then make sure to provide a way  to  mark  its  closure  dynamically  (i.e., caused by a previous event's processing).  Suppose you receive 100 events from epoll_wait(2), and in event  #47 a condition causes event #13 to be closed.  If you remove the structure and close(2) the file descriptor for event #13,  then  your event cache might still say there are events waiting for that file descriptor causing confusion.          

    One  solution  for  this  is  to  call,  during  the  processing of event 47, epoll_ctl(EPOLL_CTL_DEL) to delete file descriptor 13 and close(2), then mark its associated data structure as removed and link it to a cleanup  list.   If  you  find  another  event  for  file  descriptor  13  in your batch processing, you will discover the file descriptor had been previously removed and there will be no confusion.(对此的一种解决方案是,在处理事件47期间调用epoll_ctl(EPOLL_CTL_DEL)删除文件描述符13并关闭,然后将其关联的数据结构标记为已删除,并将其链接到一个清理列表。如果您在批处理中发现文件描述符13的另一个事件,那么您将发现该文件描述符已经被删除,不会造成混淆。)

 

webbench使用:

 

./webbench -t 10 -c 100 --get http://127.0.0.1:8888/hello

 

 

muduo

muduo是一个基于Reactor模式的C++网络库。它采用非阻塞I/O模型,基于事件驱动和回调。我们不仅可以通过muduo来学习linux服务端多线程编程,还可以通过它来学习C++11。

我们可以知道,Reactor模式的基础是事件驱动,事件源可以有多个,并且会并发地产生事件。Reactor模式的核心是一个事件分发器和多个事件处理器,多个事件源向事件分发器发送事件,并要求事件分发器响应,reactor模式的设计难点也是在事件分发器,它必须能够有条不紊地把响应时间分派到合适的事件处理器中,保证事件处理的最小延迟。事件处理器主要是负责处理事件的业务逻辑,这是关系到具体事件的核心,因此和事件分发器不一样,它并不太具有一般性。
这里写图片描述

  Reactor模式的特点可以很自然地应用到C/S架构中。在C/S架构的应用程序中,多个客户端会同时向服务端发送request请求。服务端接收请求,并根据请求内容处理请求,最后向客户端发送请求结果。这里,客户端就相当于事件源,服务端由事件分发器和事件处理器组成。分发器的任务主要是解析请求和将解析后的请求发送到具体的事件处理器中。

从Reactor模式到C/S架构

从技术的层面来说,怎么把“事件”这个概念放到“请求”上,也就是怎么样使得请求到来可以触发事件,是一个难点。从设计的层面上来说,怎么样分发事件使得响应延迟最小,并保持高可扩展性是难点(架构能够较好地适应各种事件的处理和事件数量的变化)。对于技术层面,linux上的解决方案是:epoll,select等。而设计层面,muduo提供了较好的解决方案。 
    Muduo的基础设施是epoll,并在此基础上实现了one-thread-one-loop和thread-pool设计方案。也就是将事件处理器设置成线程池,每个线程对应一个事件处理器;因为事件处理器主要处理的是I/O事件,而且每个事件处理器可能会处理一个连接上的多个I/O事件,而不是处理完一个事件后直接断开,因此muduo选择每个事件处理器一个event-loop。这样,连接建立后,对于这条连接上的所有事件全权由它的事件处理器在event-loop中处理。 
    我们可以根据上面的reactor架构图,简单地绘制出muduo的架构图:
muduo架构图

或是:

 

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章