一、基本TCP套接字编程
1、socket
为了执行网络I/O,一个进程必须做的第一件事情就是调用socket函数,指定期望的通信协议类型以及地址族。
函数原型:
int socket(int family, int type, int protocol);
//若成功则返回非负描述符,若出错则为 -1
- family参数指明协议族:AF_INET(IPv4协议)、AF_INET6(IPv6协议);
- type参数指明套接字类型:SOCK_STREAM(字节流,对应TCP)、SOCK_DGRAM(数据报,对应UDP);
- protocol参数指明传输协议:IPPROTO_TCP(TCP协议)、IPPROTO_UDP(UDP协议),其实前两个参数就可以创建套接字了,操作系统会自动推演出协议类型,所以一般第3个参数填0。
2、connect
TCP客户端用connect函数来建立与TCP服务器的连接,调用connect函数就会激发TCP的三次握手过程。
函数原型:
int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen);
//成功返回0,出错返回 -1
- sockfd是由socket函数返回的套接字描述符;
- servaddr是指向套接字地址结构的指针,该结构包括了服务器的IP地址及端口号;
- addrlen表示第2个参数所指结构的大小。
3、bind
给套接字绑定IP地址与端口,即给套接字命名,这样客户端才知道该如何连接它;客户端通常不需要命名,而是采用匿名的方式,即使用操作系统自动分配的socket地址。
函数原型:
int bind(int sockfd, const struct sosckaddr *myaddr, socklen_t addrlen);
//成功返回0,出错返回 -1
- myaddr是指向一个地址结构的指针,包括IP地址及端口号;
- addrlen表示第2个参数所指结构的大小。
客户端也能够调用
bind
函数,作用是指定服务器的端口号,但如果有两个客户端同时bind
一个端口号的话,第二个绑定的bind
会报错:bind socket error.
。面试官可能会问TCP网络通信的客户端程序中的socket
是否可以调用bind
函数?答案是可以。
4、listen
socket被命名之后还不能马上接受客户端连接,需要使用listen函数来创建一个监听队列以存放待处理的客户连接,其中backlog参数指示了监听队列的最大长度,监听队列的长度如果超过backlog,服务器将不受理新的客户端连接,它只表示处于完全连接状态的socket上限,处于半连接状态的socket上限则由内核参数定义,backlog参数的典型值是5;
函数原型:
int listen(int sockfd, int backlog);
//成功返回0,出错返回 -1
- sockfd是要监听的套接字描述符;
- backlog规定了相应套接字的最大连接个数,一般情况下完全连接的上限通常比backlog略大(backlog + 1)。
注意:内核为任何一个给定的监听套接字维护两个队列:
- 未完成连接队列:服务器已收到客户端的SYN请求,等待完成三次握手过程的套接字;
- 已完成连接队列:已经完成TCP三次握手的套接字。
5、accept
accept函数由TCP服务器调用,用于从已完成连接队列队头返回下一个已完成连接的已连接套接字描述符,如果队列为空,则阻塞等待,accept不关心连接处于何种状态。
一个服务器通常仅仅创建一个监听套接字,而内核为每个由服务器接受的客户连接创建一个已连接套接字。
函数原型:
int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);
//成功返回非负套接字描述符,出错返回 -1
- sockfd是监听套接字(由socket创建,随后用作bind和listen的第一个参数的描述符);
- cliaddr和addrlen用来返回已连接的对端进程(客户)的协议地址,如果对客户端的身份不感兴趣,可以设置为空指针。
6、close
close函数用来关闭套接字,并终止TCP连接,即触发四次挥手;
但close不是立即关闭,而是将fd的引用计数减1,只有当fd的引用计数为0时才真正关闭连接。在多进程程序中,一次fork系统调用默认将使父进程中打开的socket的引用计数加1,因此我们必须在父进程和子进程中都对该socket执行close调用才能将连接关闭。如果无论如何都要立即终止连接,可以使用shutdown系统调用。
函数原型:
int close(int sockfd);
//成功返回0,出错返回 -1
- sockfd为要关闭的套接字描述符。
7、recv/send
read/write用于文件的读写,socket编程接口提供了recv/send等专门用于socket数据读写的系统调用。
套接字完整通信过程:
重点:
recv
函数返回0表示对端关闭了连接,这时服务端也应关闭这些连接。
二、I/O多路转接(复用)
先来看看不使用I/O多路转接的情况下式如何在服务器端实现并发的,即使用多进程/多线程并发。
1、 多线程/多进程并发
主进程调用
accept()
阻塞检测客户端请求,如果有新请求就解除阻塞并fork一个子进程与客户端建立连接,子进程调用read()/recv()
阻塞接收客户端的数据,数据到达会自动解除阻塞,调用write()/send()
给客户端发送数据,如果写缓冲区已满就阻塞。多线程并发流程类似,不过多线程开销小,还可以用线程池进行优化。线程池使得线程可以复用,就是执行完一个任务,并不被销毁,而是可以继续执行其他的任务。
线程池主要分为三个部分:
- 任务队列:存储需要处理的任务,相当于生产者,已处理的任务会从任务队列中被移除;
- 工作线程:线程池中维护了一定数量的工作线程,它们会不停地读任务队列,从中取出任务并处理,工作线程相当于消费者。若任务队列为空,则工作线程阻塞,有新任务后会接触阻塞;
- 管理者线程:周期性地检测任务队列中任务数量及忙线程数量,当任务过多时创建一些新线程,任务过少时销毁一些线程。
使用I/O多路转接函数会委托内核检测服务器端所有的文件描述符,检测的过程会导致进程/线程阻塞,但当检测完毕得到已就绪的这些文件描述符后,调用
accept()/read()/write()
等函数对其进行处理时不会导致程序阻塞。与多线程/多进程方式相比,I/O多路转接的好处是系统开销小,不必创建进程/线程,也不必维护它们。
2、 select
函数原型:
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval * timeout);
- 第1个参数是委托内核检测的最大文件描述符个数+1,内核会线性遍历这些文件描述符;
- 第2、3、4个参数分别表示检测集合中文件描述符对应的读缓冲区(读集合是必须检测的,这样才知道通过哪个文件描述符接收数据)、写缓冲区(可以为NULL)、异常状态(可以为NULL);
- 第5个参数是超时时长,用来强制解除select()函数的阻塞。
通过调用select()函数可以委托内核帮助我们检测若干文件描述符的状态(读缓冲区、写缓冲区、读写异常),循环调用select(),周期性检测所有文件描述符,解除阻塞后得到内核传出的就绪文件描述符集合。
缺点:
- 待检测集合(第2、3、4个参数)需要频繁的在用户区与内核区之间进行数据拷贝,效率低;
- 内核对于select传递进来的待检测集合的检测方式是线性的,即文件描述符越多检测效率越低;
- 能够检测的最大文件描述符个数上限是1024,这是在内核中写死了的。
3、 poll
函数原型:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
- 第1个参数是个结构体数组,里面存储了委托内核检测的文件描述符、委托内核检测的fd事件(输入、输出、错误)、检测后的结果(传出参数);
- 第2个参数是第一个参数数组中最后一个有效元素的下标+1;
- 第3个参数指定poll函数的阻塞时长。
与select比较:
- 缺点同select的前两条;
- poll没有最大文件描述符数量的限制;
- select可以跨平台使用,poll只能在linux平台使用;
4、 epoll
函数原型:
// 创建epoll实例,通过一棵红黑树管理待检测集合
int epoll_create(int size);
// 管理红黑树上的文件描述符(添加、修改、删除)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 检测epoll树中是否有就绪的文件描述符
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
select/poll 低效的原因之一是将 “添加 / 维护待检测任务” 和 “阻塞进程 / 线程” 两个步骤合二为一。每次调用 select 都需要这两步操作,然而大多数应用场景中,需要监视的 socket 个数相对固定,并不需要每次都修改。epoll 将这两个操作分开,先用 epoll_ctl() 维护等待队列,再调用 epoll_wait() 阻塞进程(解耦),使效率得到了提升。
当多路复用的文件数量庞大、IO流量频繁的时候,select()和poll()表现较差,推荐使用epoll()。
与select、poll比较:
- 对于待检测集合select和poll是基于线性方式处理的,epoll是基于红黑树来管理待检测集合的;
- select和poll每次都会线性扫描整个待检测集合,集合中文件描述符数量越多速度就越慢,epoll使用回调机制,处理效率不会随着检测集合变大而下降;
- select和poll工作过程中存在内核/用户空间数据的频繁拷贝问题,在epoll中内核和用户区使用的是共享内存(基于mmap内存映射区实现),省去了不必要的内存拷贝;
- 程序猿需要对select和poll返回的集合进行判断才能知道哪些文件描述符是就绪的,通过epoll可以直接得到已就绪的文件描述符集合,无需再次检测;
- epoll没有最大文件描述符的限制。
epoll有两种工作模式:
水平模式(LT)
在这种模式下,内核通知使用者哪些文件描述符已经就绪,之后就可以对这些已就绪的文件描述符进行 IO 操作了。只要缓冲区还有数据没读完,内核就会一直通知。
边沿模式(ET)
在这种模式下,当文件描述符从未就绪变为就绪时,内核会通过epoll通知使用者。然后它会假设使用者知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知。因为每次只通知一次,ET模式很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。
ET比LT更高效的原因
ET在通知用户后,就会把fd从就绪队列里删除。而LT通知用户后fd还在就绪链表中,随着fd的增多,就绪链表越大。下次epoll要通知用户时还需要遍历整个就绪链表。遍历的性能是线性,如果fd的数量非常多,就会带来比较显著的效率下降。同样数量的fd下,LT模式维护的就绪链表比ET的大。
ET和LT模式各自应用场景是什么?为什么有了高效的ET还需要LT?
- LT的编程更符合用户直觉,业务层逻辑更简单,不易出错,但效率较低;
- ET的编程可以做到更加简洁,某些场景下更加高效,但另一方面容易遗漏事件,容易产生bug;
- 对于nginx这种高性能服务器,ET模式是很好的,Redis使用的是LT,避免使用的过程中出现bug;
- 当并发量比较小时,比较推荐LT,因为LT模式下应用的读写逻辑比较简单,不容易遗漏事件,代码不易出错好维护,而且性能损失不大。当并发量非常大时,推荐使用ET模式,可以有效提升EPOLL效率。
参考文献
苏丙榅的博客
《UNIX网络编程 卷1:套接字联网API》
《Linux高性能服务器编程》
写的真好