C++原生Socket编程

Socket基础知识介绍

  1. socket是使用标准Unix文件描述符(file descriptior)和其他程序通信的方式。
  2. 既然是文件描述符,我们就可以使用read()和write()函数来进行套接字通信,但是使用send()和recv()能让我们更好的控制数据传输。
  3. 两种常用套接字分别是“Stream Sockets”(流格式:SOCK_STREAM),另外一种是“Datagram Sockets”(数据包格式:SOCK_DGRAM)。
  4. 流套接字是可靠的双向通讯的数据流,使用TCP协议,并且按顺序传输,无错误传递,有自己的错误控制,数据报套接字也称为无连接套接字,使用UDP协议(如果确实需要连接可以使用connect())。

    Socket结构体

    struct sockaddr {
      unsigned short sa_family; /* 地址家族, AF_xxx */
      char sa_data[14]; /*14字节协议地址*/
    };
    

    sa_family 能够是各种各样的类型,但是大家主流使用都是 “AF_INET”。 sa_data包含套接字中的目标地址和端口信息。为了处理struct sockaddr,程序员创造了一个并列的结构: struct sockaddr_in ("in" 代表 "Internet"。)

    struct sockaddr_in {
      short int sin_family; /* 通信类型 */
      unsigned short int sin_port; /* 端口 */
      struct in_addr sin_addr; /* Internet 地址 */
      unsigned char sin_zero[8]; /* 与sockaddr结构的长度相同*/
    };
    

    用这个数据结构可以轻松处理套接字地址的基本元素。注意 sin_zero (它被加入到这个结构,并且长度和 struct sockaddr 一样) 应该使用函数 bzero() 或 memset() 来全部置零。 同时,这一重要的字节,一个指向 sockaddr_in结构体的指针也可以被指向结构体sockaddr并且代替它。这样的话即使 socket() 想要的是 struct sockaddr *,你仍然可以使用 struct sockaddr_in,并且在最后转换。同时,注意 sin_family 和 struct sockaddr 中的 sa_family 一致并能够设置为 “AF_INET”。最后,sin_port和 sin_addr 必须是网络字节顺序 (Network Byte Order)!

    struct in_addr {
      unsigned long s_addr;
    };
    

    本机网络字节序转换

  5. 假设你想将 short 从本机字节顺序转 换为网络字节顺序。用 “h” 表示 “本机 (host)”,接着是 “to”,然后用 “n” 表 示 “网络 (network)”,最后用 “s” 表示 “short”: h-to-n-s, 或者 htons() (“Host to Network Short”)。总共有: htons()–“Host to Network Short” htonl()–“Host to Network Long” ntohs()–“Network to Host Short” ntohl()–“Network to Host Long”
  6. 为什么在数据结构 struct sockaddr_in 中, sin_addr 和 sin_port 需要转换为网络字节顺序,而sin_family 需不需要呢? 答案是: sin_addr 和 sin_port 分别封装在包的 IP 和 UDP 层。因此,它们必须要 是网络字节顺序。但是 sin_family 域只是被内核 (kernel) 使用来决定在数 据结构中包含什么类型的地址,所以它必须是本机字节顺序。同时, sin_family 没有发送到网络上,它们可以是本机字节顺序。

    IP地址转换

  7. 假设你已经有了一个sockaddr_in结构体ina,你有一个IP地 址”132.241.5.10”要储存在其中,你就要用到函数inet_addr(),将IP地址从 点数格式转换成无符号长整型。使用方法如下:
    ina.sin_addr.s_addr = inet_addr("132.241.5.10");
    

    注意,inet_addr()返回的地址已经是网络字节格式,所以你无需再调用 函数htonl()。 我们现在发现上面的代码片断不是十分完整的,因为它没有错误检查。 显而易见,当inet_addr()发生错误时返回-1。记住这些二进制数字?(无符 号数)-1仅仅和IP地址255.255.255.255相符合!这可是广播地址!大错特 错!记住要先进行错误检查。

  8. 将长整形转换为ip地址,也就是把in_addr结构体转换成点数格式的函数是 inet_ntoa(ina.sin_addr)需要注意的是inet_ntoa()将结构体in-addr作为一个参数,不是长整形。同样需要注意的是它返回的是一个指向一个字符的 指针。它是一个由inet_ntoa()控制的静态的固定的指针,所以每次调用 inet_ntoa(),它就将覆盖上次调用时所得的IP地址。假如你需要保存这个IP地址,使用strcopy()函数来指向你自己的字符指针。

    Socket系统调用

  9. socket函数调用如下:
    #include <sys/types.h>
    #include <sys/socket.h>
    int socket(int domain, int type, int protocol);
    

    首先,domain 应该设置成 “AF_INET”,就 象上面的数据结构struct sockaddr_in 中一样。然后,参数 type 告诉内核 是 SOCK_STREAM 类型还是 SOCK_DGRAM 类型。最后,把 protocol 设置为 “0”。(注意:有很多种 domain、type,我不可能一一列出了,请看 socket() 的 man帮助。当然,还有一个”更好”的方式去得到 protocol,同 时请查阅 getprotobyname() 的 man 帮助。) socket() 只是返回你以后在系统调用种可能用到的 socket 描述符,或 者在错误的时候返回-1。全局变量 errno 中将储存返回的错误值。(请参考 perror() 的 man 帮助。)

  10. bind()函数。 一旦你有一个套接字,你可能要将套接字和机器上的一定的端口关联 起来。(如果你想用listen()来侦听一定端口的数据,这是必要一步。如果你只想用 connect(),那么这个步 骤没有必要。
    #include <sys/types.h>
    #include <sys/socket.h>
    int bind(int sockfd, struct sockaddr *my_addr, int addrlen);
    

    sockfd 是调用 socket 返回的文件描述符。my_addr 是指向数据结构 struct sockaddr 的指针,它保存你的地址(即端口和 IP 地址) 信息。 addrlen 设置为 sizeof(struct sockaddr)。因系统的不同,包含的头文件也不尽相同。在处理自己的 IP 地址和/或端口的 时候,有些工作是可以自动处理的。

    my_addr.sin_port = 0; /* 随机选择一个没有使用的端口 */
    my_addr.sin_addr.s_addr = INADDR_ANY; /* 使用自己的IP地址 */
    

    上述代码并没有将INADDR_ANY转换为网络字节顺序,其实INADDR_ANY就是0,当然为了更好的工作我们可以执行网络字节转换。 不要采用小于 1024的端口号。所有小于1024的端口号都被系统保留!你可以选择从1024 到65535的端口(如果它们没有被别的程序使用的话)。有时候其实我们并不需要用到bind()函数,如果你使用connect() 来和远程机器进行通讯,你不需要关心你的本地端口号,你只要简单的调用connect()就可以了,它会检查套接字是否绑定端口,如果没有,它会自己绑定一个没有使用的本地端口。

  11. connect()函数,connect()系统调用是这样的:
    #include <sys/types.h>
    #include <sys/socket.h>
    int connect(int sockfd, struct sockaddr *serv_addr, int addrlen);
    

    sockfd 是系统调用 socket() 返回的套接字文件描述符。serv_addr是保存着目的地端口和IP 地址的数据结构struct sockaddr。addrlen设置为sizeof(struct sockaddr)。再一次,你应该检查connect()的返回值–它在错误的时候返回-1,并设置全局错误变量errno。同时,你可能看到,我没有调用 bind()。因为我不在乎本地的端口号。 我只关心我要去哪。内核将为我选择一个合适的端口号,而我们所连接的 地方也自动地获得这些信息。一切都不用担心。

  12. listen()函数。等待接入请求并且用各种方法处理它们。处 理过程分两步:首先,你听–listen(),然后,你接受–accept()。
    int listen(int sockfd, int backlog);
    

    sockfd 是调用 socket() 返回的套接字文件描述符。backlog 是在进入 队列中允许的连接数目。什么意思呢? 进入的连接是在队列中一直等待直 到你接受 (accept() )连接。它们的数目限制于队列的允许。 大多数系统的允许数目是20,你也可以设置为5到10。在你调用 listen() 前你或者要调用 bind() 或者让内 核随便选择一个端口。如果你想侦听进入的连接,那么系统调用的顺序可能是这样的:

    socket();
    bind();
    listen();
    /* accept() 应该在这 */
    
  13. accept()函数。有人从很远的地方通过一个你在侦听 (listen()) 的端口连接 (connect()) 到你的机器。它的连接将加入到等待接受 (accept()) 的队列 中。你调用 accept() 告诉它你有空闲的连接。它将返回一个新的套接字文 件描述符!这样你就有两个套接字了,原来的一个还在侦听你的那个端口, 新的在准备发送(send())和接收(recv())数据。
    #include <sys/socket.h>
    int accept(int sockfd, void *addr, int *addrlen);
    

    sockfd相当简单,是和listen()中一样的套接字描述符。addr是个指向局部的数据结构 sockaddr_in 的指针。这是要求接入的信息所要去的地方(你可以测定那个地址在那个端口呼叫你)。在它的地址传递给accept之前,addrlen是个局部的整形变量,设置为sizeof(struct sockaddr_in)。accept将不会将多余的字节给addr。如果你放入的少些,那么它会通过改变 addrlen的值反映出来。 注意,在系统调用 send() 和 recv() 中你应该使用新的套接字描述符 new_fd。如果你只想让一个连接进来,那么你可以使用 close() 去关闭原 来的文件描述符 sockfd 来避免同一个端口更多的连接。

  14. send()andrecv()函数。这两个函数用于流式套接字或者数据报套接字的通讯。如果你喜欢使 用无连接的数据报套接字,你应该看一看下面关于sendto() 和 recvfrom() 的章节。 send() 是这样的:
    int send(int sockfd, const void *msg, int len, int flags);
    

    sockfd 是你想发送数据的套接字描述符(或者是调用 socket() 或者是 accept() 返回的。)msg 是指向你想发送的数据的指针。len 是数据的长度。 把 flags 设置为 0 就可以了。(详细的资料请看send()的 man page)。send() 返回实际发送的数据的字节数–它可能小于你要求发送的数 目! 注意,有时候你告诉它要发送一堆数据可是它不能处理成功。它只是 发送它可能发送的数据,然后希望你能够发送其它的数据。记住,如果 send() 返回的数据和 len 不匹配,你就应该发送其它的数据。但是这里也 有个好消息:如果你要发送的包很小(小于大约 1K),它可能处理让数据一 次发送完。最后要说得就是,它在错误的时候返回-1,并设置 errno。
    recv() 函数很相似:

    int recv(int sockfd, void *buf, int len, unsigned int flags);
    

    sockfd 是要读的套接字描述符。buf是要读的信息的缓冲。len是缓冲的最大长度。flags 可以设置为0。(请参考recv() 的 man page。) recv() 返回实际读入缓冲的数据的字节数。或者在错误的时候返回-1, 同时设置errno。

  15. 既然数据报套接字不是连接到远程主机的,那么在我们发送一个包之前需要什么信息呢? 不错,是目标地址!看看下面的:
    int sendto(int sockfd, const void *msg, int len, unsigned int flags,
    const struct sockaddr *to, int tolen);
    

    to 是个指向数据结构 struct sockaddr 的指针,它包含了目的地的 IP 地址和端口信息。tolen 可以简单地设置为 sizeof(struct sockaddr)。 和函数 send() 类似,sendto() 返回实际发送的字节数(它也可能小于 你想要发送的字节数!),或者在错误的时候返回 -1。
    recvfrom() 的定义是这样的:

    int recvfrom(int sockfd, void *buf, int len, unsigned int flags,  
    struct sockaddr *from, int *fromlen);
    

    from 是一个指向局部数据结构 struct sockaddr 的指针,它的内容是源机器的 IP 地址和端口信息。fromlen 是个 int 型的局部指针,它的初始值为 sizeof(struct sockaddr)。函数调用返回后,fromlen 保存着实际储存在 from 中的地址的长度。recvfrom() 返回收到的字节长度,或者在发生错误后返回 -1。 记住,如果你用 connect() 连接一个数据报套接字,你可以简单的调 用 send() 和 recv() 来满足你的要求。这个时候依然是数据报套接字,依 然使用 UDP,系统套接字接口会为你自动加上了目标和源的信息。

  16. 关闭套接字描述符使用close函数close(sockfd);,如果你想在关闭套接字上有多一点的控制,可以使用函数shutdowm()。它允许你将一定方向上的通讯或者双向的通讯(就象close()一 样)关闭,你可以使用:int shutdown(int sockfd, int how);
    sockfd 是你想要关闭的套接字文件描述复。how 的值是下面的其中之一:
    0 – 不允许接受
    1 – 不允许发送
    2 – 不允许发送和接受(和 close() 一样)
    shutdown() 成功时返回 0,失败时返回 -1(同时设置 errno。) 如果在无连接的数据报套接字中使用shutdown(),那么只不过是让 send() 和 recv() 不能使用(记住你在数据报套接字中使用了 connect 后 是可以使用它们的)。
  17. getpeername()函数。函数 getpeername() 告诉你在连接的流式套接字上谁在另外一边。
    #include <sys/socket.h>
    int getpeername(int sockfd, struct sockaddr *addr, int *addrlen);
    

    sockfd 是连接的流式套接字的描述符。addr 是一个指向结构 struct sockaddr (或者是 struct sockaddr_in) 的指针,它保存着连接的另一边的 信息。addrlen 是一个 int 型的指针,它初始化为 sizeof(struct sockaddr)。 函数在错误的时候返回 -1,设置相应的 errno。一旦你获得它们的地址,你可以使用 inet_ntoa() 或者 gethostbyaddr() 来打印或者获得更多的信息。但是你不能得到它的帐号。

  18. gethostname()函数。甚至比 getpeername()还简单的函数是 gethostname()。它返回你程序所运行的机器的主机名字。然后你可以使用gethostbyname() 以获得你的机器的IP 地址。
    #include <unistd.h>
    int gethostname(char *hostname, size_t size);
    

    参数很简单:hostname 是一个字符数组指针,它将在函数返回时保存 主机名。size是hostname 数组的字节长度。 函数调用成功时返回 0,失败时返回 -1,并设置 errno。

    域名服务(DNS)

  19. DNS主要功能是:你给它一个容易记忆的某站点的地址,它给你IP地址(然后你就可以使用bind(),connect(),sendto()或者其它函数)。函数gethostbyname()如下:
    #include <netdb.h>
    struct hostent *gethostbyname(const char *name);
    

    它返回一个指向struct hostent的指针,这个结构体如下:

    struct hostent {
      char *h_name;
      char **h_aliases;
      int h_addrtype;
      int h_length;
      char **h_addr_list;
    };  
    

    h_name – 地址的正式名称。
    h_aliases – 空字节-地址的预备名称的指针。
    h_addrtype –地址类型; 通常是AF_INET。
    h_length – 地址的比特长度。
    h_addr_list – 零字节-主机网络地址指针。网络字节顺序。
    h_addr - h_addr_list中的第一地址。
    gethostbyname() 成功时返回一个指向结构体 hostent的指针,或者 是个空 (NULL) 指针。(但是和以前不同,不设置errno,h_errno 设置错误信息。

    printf("Host name : %s\n", h->h_name);
    printf("IP Address : %s\n",inet_ntoa(*((struct in_addr *)h->h_addr)));
    

    在使用 gethostbyname() 的时候,你不能用 perror() 打印错误信息 (因为 errno 没有使用),你应该调用 herror()。相当简单,你只是传递一个保存机器名的字符串(例如 “whitehouse.gov”) 给 gethostbyname(),然后从返回的数据结构 struct hostent 中获取信息。唯一也许让人不解的是输出 IP 地址信息。h->h_addr 是一个 char *, 但是 inet_ntoa() 需要的是 struct in_addr。因此,我转换 h->h_addr 成 struct in_addr *,然后得到数据。

    阻塞

  20. 很多函数都利用阻塞。accept()阻塞,所有的recv*()函数阻塞。它们之所以能这样做是因为它们被允许这样做。当你第一次调用 socket()建立套接字描述符的时候,内核就将它设置为阻塞。如果你不想套接字阻塞,你就要调用函数fcntl():
    #include <unistd.h>
    #include <fontl.h>
    ……
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    fcntl(sockfd, F_SETFL, O_NONBLOCK);
    ……
    

    通过设置套接字为非阻塞,你能够有效地”询问”套接字以获得信息。如果你尝试着从一个非阻塞的套接字读但是一般说来,这种询问不是个好主意。如果你让你的程序在忙等状 态查询套接字的数据,你将浪费大量的CPU时间。更好的解决之道是用下一章讲的 select()去查询是否有数据要读进来。信息并且没有任何数据,它不允许阻塞–它将返回-1并将errno设置为EWOULDBLOCK。

  21. select()–多路同步 I/O
    假设这样的情况:你是个服务器,你一边在不停地从连接上读数据,一边在侦听连接上的信息。没问题,你可能会说,不就是一个accept()和两个recv()吗?但是当accept()阻塞时就不能同时接收recv()数据了,此时使用“非阻塞的套接字”就会耗尽所有的CPU。此时使用selec()函数可以让我们同时监视多个套接字,它能告诉我们哪个套接字准备读,哪个又准备写,哪个套接字又发生了例外(exception)。select函数如下:
    #include <sys/time.h>
    #include <sys/types.h>
    #include <unistd.h>
    int select(int numfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
    

    这个函数监视一系列文件描述符,特别是 readfds、writefds和exceptfds。如果你想知道你是否能够从标准输入和套接字描述符sockfd读入数据,你只要将文件描述符0和sockfd加入到集合 readfds中。参数numfds应该等于最高的文件描述符的值加1。在这个例子中,你应该设置该值为 sockfd+1。因为它一定大于标准输入的文件描述符(0)。当函数select()返回的时候,readfds 的值修改为反映你选择的哪个文件描述符可以读。你可以用下面讲到的宏FD_ISSET()来测试。在我们继续下去之前,让我来讲讲如何对这些集合进行操作。每个集合类型都是fd_set。下面有一些宏来对这个类型进行操作:

    FD_ZERO(fd_set *set) – 清除一个文件描述符集合
    FD_SET(int fd, fd_set *set) - 添加fd到集合
    FD_CLR(int fd, fd_set *set) – 从集合中移去fd
    FD_ISSET(int fd, fd_set *set) – 测试fd是否在集合中
    

    数据结构struct timeval。有时你可不想永远等待别人发送数据过来。也许什么事情都没有发生的时候你也想每隔96秒在终端上打印字符串 “Still Going…“。这个数据结构允许你设定一个时间,如果时间到了,而select()还没有找到一个准备好的文件描述符,它将返回让你继续处理。数据结构struct timeval是这样的:

    struct timeval {
    int tv_sec; /* seconds */、
    int tv_usec; /* microseconds */
    };
    

    只要将tv_sec设置为你要等待的秒数,将tv_usec设置为你要等待的微秒数就可以了。但是标准的 Unix系统的时间片是100毫秒,所以无论你如何设置你的数据结构 struct timeval,你都要等待那么长的时间。如果你设置数据结构struct timeval中的数据为0,select()将立即超时,这样就可以有效地轮询集合中的所有的文件描述符。如果你将参数timeout赋值为NULL,那么将永远不会发生超时,即一直等到第一个文件描述符就绪。最后,如果你不是很关心等待多长时间,那么就把它赋为NULL吧。
    如果你有一个正在侦听(listen())的套接字,你可以通过将该套接字的文件描述符加入到 readfds集合中来看是否有新的连接。

  22. epoll。当服务端的人数越来越多,会导致资源吃紧,I/O效率越来越低,这时就应该考虑epoll,epoll是Linux内核为处理大量句柄而改进的poll,是linux特有的I/O函数。其特点如下:
    1)epoll是Linux下多路复用IO接口select/poll的增强版本,其实现和使用方式与select/poll大有不同,epoll通过一组函数来完成有关任务,而不是一个函数。
    2)epoll之所以高效,是因为epoll将用户关心的文件描述符放到内核里的一个事件列表中,而不是像select/poll每次调用都需要重复传入文件描述符集或事件集(大量拷贝开销),比如一个事件发生,epoll无需遍历整个被监听的描述符集,而只需要遍历哪些被内核IO事件异步唤醒而加入就绪队列的描述符集合即可。
    3)epoll有两种工作方式,LT(Level triggered) 水平触发 、ET(Edge triggered)边沿触发。LT是select/poll的工作方式,比较低效,而ET是epoll具有的高速工作方式。
    Epoll 用法(三步曲):
    第一步:int epoll_create(int size)系统调用,创建一个epoll句柄,参数size用来告诉内核监听的数目,size为epoll支持的最大句柄数。
    第二步:int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)  事件注册函数。参数 epfd为epoll的句柄。参数op 表示动作 三个宏来表示:EPOLL_CTL_ADD注册新fd到epfd 、EPOLL_CTL_MOD 修改已经注册的fd的监听事件、EPOLL_CTL_DEL从epfd句柄中删除fd。参数fd为需要监听的标识符。参数结构体epoll_event告诉内核需要监听的事件。
    第三步:int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout) 等待事件的产生,通过调用收集在epoll监控中已经发生的事件。参数struct epoll_event 是事件队列 把就绪的事件放进去。
    服务端使用epoll的时候步骤如下:
    1.调用epoll_create()在linux内核中创建一个事件表。
    2.然后将文件描述符(监听套接字listener)添加到事件表中
    3.在主循环中,调用epoll_wait()等待返回就绪的文件描述符集合。
    4.分别处理就绪的事件集合,本项目中一共有两类事件:新用户连接事件和用户发来消息事件。
  23. 在使用epoll时,在函数 epoll_ctl中如果不设定,epoll_event 的event默认为LT(水平触发)模式。使用LT模式意味着只要fd处于可读或者可写状态,每次epoll_wait都会返回该fd,这样的话会带来很大的系统开销,且处理时候每次都需要把这些fd轮询一遍,如果fd的数量巨大,不管有没有事件发生,epoll_wait都会触发这些fd的轮询判断。
    在ET模式下,当有事件发生时,系统只会通知你一次,即在调用epoll_wait返回fd后,不管这个事件你处理还是没处理,处理完没有处理完,当再次调用epoll_wait时,都不会再返回该fd,这样的话程序员要自己保证在事件发生时要及时有效的处理完该事件。例如:fd发生了IN事件,在调用epoll_wait后发现了该时间,程序员要保证在本次轮询中对该fd做了读操作,且还要循环调用recv操作,直到读到的recv的返回值小于请求值,或者遇到EAGAIN错误,否则,在下次轮询时,如果该fd没有再次触发事件,你就没有机会知道这个fd需要处理。这样就会增加程序员的负担和出错的机会(可能有些数据没有来得及处理,丢失数据)。
    在LT模式下,无论fd是否有事件发生,或者还有一些事件没有处理完,每次调用epoll_wait时,总会得到该fd让你处理(只要有没事件没有处理,会一直通知你处理,直到你处理完为止,这样就保证了数据的不丢失)。 操作系统在LT模式下维护的就绪队列大小相对于ET模式肯定大,且LT轮询所有的fd总比ET轮询的fd大。自然在性能上LT不如ET,但是在使用ET模式的时,需要循环调用recv,send等处理函数,得保证其事件处理完毕,这样也会带来开销且容易出错。 从 kernel 代码来看,ET/LT模式的处理逻辑几乎完全相同,差别仅在于 LT模式在 event 发生时不会将其从 ready list 中移除,略为增大了event 处理过程中 kernel space 中记录数据的大小。总结:
    1.epoll的ET和LT模式处理逻辑差异极小,性能测试结果表明常规应用场景中二者性能差异可以忽略。
    2.使用ET的程序比使用LT的逻辑复杂,出错概率更高。
    3.ET和LT的性能差异主要在于epoll_wait系统调用的处理速度,是否是程序的性能瓶颈需要视应用场景而定,不可一概而论。
  24. poll函数,函数原型:
    #include <poll.h>
    int poll ( struct pollfd * fds, unsigned int nfds, int timeout);
    

    参数介绍: fds : 对应上述介绍的结构体指针
    nfds : 标记数组中结构体元素的总个数。
    timeout : 超时时间 ,等于0表示非阻塞式等待,小于0表示阻塞式等待,大于0表示等待的时间

返回值: 成功时返回fds数组中事件就绪的文件描述符的个数
返回0表示超时时间到了。
返回-1表示调用失败,对应的错误码会被设置。
poll采用一个pollfd指针向内核传递需要关心的描述符及其相关事件。

struct pollfd{
    int fd;              //file descriptor
    short events;        //requeseted events
    short revents;       //returned events
}

fd : 需要关心的文件描述符
events : 需要关心的事件,合法事件如下

POLLIN         有数据可读。
POLLRDNORM       有普通数据可读。
POLLRDBAND      有优先数据可读。
POLLPRI        有紧迫数据可读。
POLLOUT         写数据不会导致阻塞。
POLLWRNORM       写普通数据不会导致阻塞。
POLLWRBAND       写优先数据不会导致阻塞。
POLLMSGSIGPOLL     消息可用。

revents:关心的事件就绪时revents会被设置为上述对应的事件,除此之外还可能设置为如下内容

POLLER    指定的文件描述符发生错误。
POLLHUP   指定的文件描述符挂起事件。
POLLNVAL  指定的文件描述符非法。
  1. select实现
    1、使用copy_from_user从用户空间拷贝fd_set到内核空间
    2、注册回调函数__pollwait
    3、遍历所有fd,调用其对应的poll方法(对于socket,这个poll方法是sock_poll, sock_poll根据情况会调用到tcp_poll,udp_poll或者datagram_poll)
    4、以tcp_poll为例,其核心实现就是__pollwait,也就是上面注册的回调函数。
    5、__pollwait的主要工作就是把current(当前进程)挂到设备的等待队列中,不同的设备有不同的等待队列,对于tcp_poll来说,其等待队列是sk->sk_sleep(注意把进程挂到等待队列中并不代表进程已经睡眠了)。在设备收到一条消息(网络设备)或填写完文件数据(磁盘设备)后,会唤醒设备等待队列上睡眠的进程,这时current便被唤醒了。
    6、poll方法返回时会返回一个描述读写操作是否就绪的mask掩码,根据这个mask掩码给fd_set赋值。
    7、如果遍历完所有的fd,还没有返回一个可读写的mask掩码,则会调用schedule_timeout是调用select的进程(也就是current)进入睡眠。当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的进程。如果超过一定的超时时间(schedule_timeout指定),还是没人唤醒,则调用select的进程会重新被唤醒获得CPU,进而重新遍历fd,判断有没有就绪的fd。
    poll的实现和select非常相似,只是描述fd集合的方式不同,poll使用pollfd结构而不是select的fd_set结构。其他的都差不多。
  2. epoll函数底层实现
    首先epoll_create创建一个epoll文件描述符,底层同时创建一个红黑树,和一个就绪链表;红黑树存储所监控的文件描述符的节点数据,就绪链表存储就绪的文件描述符的节点数据;epoll_ctl将会添加新的描述符,首先判断是红黑树上是否有此文件描述符节点,如果有,则立即返回。如果没有, 则在树干上插入新的节点,并且告知内核注册回调函数。当接收到某个文件描述符过来数据时,那么内核将该节点插入到就绪链表里面。epoll_wait将会接收到消息,并且将数据拷贝到用户空间,清空链表。对于LT模式epoll_wait清空就绪链表之后会检查该文件描述符是哪一种模式,如果为LT模式,且必须该节点确实有事件未处理,那么就会把该节点重新放入到刚刚删除掉的且刚准备好的就绪链表,epoll_wait马上返回。ET模式不会检查,只会调用一次。
  3. select、poll、epoll优缺点对比 select的优缺点
    优点:
    (1)select的可移植性好,在某些unix下不支持poll.
    (2)select对超时值提供了很好的精度,精确到微秒,而poll是毫秒。
    缺点:
    (1)单个进程可监视的fd数量被限制,默认是1024。
    (2)需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。
    (3)对fd进行扫描时是线性扫描,fd剧增后,IO效率降低,每次调用都对fd进行线性扫描遍历,随着fd的增加会造成遍历速度慢的问题。
    (4)select函数超时参数在返回时也是未定义的,考虑到可移植性,每次超时之后进入下一个select之前都要重新设置超时参数。
    poll函数的优缺点
    优点:
    (1)不要求计算最大文件描述符+1的大小。
    (2)应付大数量的文件描述符时比select要快。
    (3)没有最大连接数的限制是基于链表存储的。
    缺点:
    (1)大量的fd数组被整体复制于内核态和用户态之间,而不管这样的复制是不是有意义。
    (2)同select相同的是调用结束后需要轮询来获取就绪描述符。
    epoll函数的优缺点 优点: epoll的优点: (1)支持一个进程打开大数目的socket描述符(FD)
    select 最不能忍受的是一个进程所打开的FD是有一定限制的,由FD_SETSIZE设置,默认值是2048。对于那些需要支持的上万连接数目的IM服务器来说显 然太少了。这时候你一是可以选择修改这个宏然后重新编译内核,不过资料也同时指出这样会带来网络效率的下降,二是可以选择多进程的解决方案(传统的 Apache方案),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完 美的方案。不过 epoll则没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左 右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。
    (2)IO效率不随FD数目增加而线性下降
    传统的select/poll另一个致命弱点就是当你拥有一个很大的socket集合,不过由于网络延时,任一时间只有部分的socket是”活跃”的, 但是select/poll每次调用都会线性扫描全部的集合,导致效率呈现线性下降。但是epoll不存在这个问题,它只会对”活跃”的socket进行 操作—这是因为在内核实现中epoll是根据每个fd上面的callback函数实现的。那么,只有”活跃”的socket才会主动的去调用 callback函数,其他idle状态socket则不会,在这点上,epoll实现了一个”伪”AIO,因为这时候推动力在os内核。在一些 benchmark中,如果所有的socket基本上都是活跃的—比如一个高速LAN环境,epoll并不比select/poll有什么效率,相 反,如果过多使用epoll_ctl,效率相比还有稍微的下降。但是一旦使用idle connections模拟WAN环境,epoll的效率就远在select/poll之上了。
    (3)使用mmap加速内核与用户空间的消息传递。
    这点实际上涉及到epoll的具体实现了。无论是select,poll还是epoll都需要内核把FD消息通知给用户空间,如何避免不必要的内存拷贝就 很重要,在这点上,epoll是通过内核于用户空间mmap同一块内存实现的。而如果你想我一样从2.5内核就关注epoll的话,一定不会忘记手工 mmap这一步的。
    (4)内核微调
    这一点其实不算epoll的优点了,而是整个linux平台的优点。也许你可以怀疑linux平台,但是你无法回避linux平台赋予你微调内核的能力。比如,内核TCP/IP协议栈使用内存池管理sk_buff结构,那么可以在运行时期动态调整这个内存pool(skb_head_pool)的大小 — 通过echo XXXX>/proc/sys/net/core/hot_list_length完成。再比如listen函数的第2个参数(TCP完成3次握手 的数据包队列长度),也可以根据你平台内存大小动态调整。更甚至在一个数据包面数目巨大但同时每个数据包本身大小却很小的特殊系统上尝试最新的NAPI网卡驱动架构。