bind
- bind函数把一个本地协议地址赋予一个套接字。将sockaddr设置的ip和port绑定在套接字上,如果sockaddr的sin_port指定为0,则会自动分配一个未占用的端口。如果指定IP为通配地址,且server有多个网卡,不确定client会从哪个网卡连进来,所以bind ip的过程推辞,内核会在与client建立连接后选择一个本地IP地址。服务器必须bind。
- client通过connect建立连接,只需要知道目的IP和port,所以并不需要bind。
- 但是client也不是不能bind,如果client bind了端口以后会带来什么后果呢。 如果在client的程序里,bind了某个端口(比如 3456)。首先,得考虑这个端口是否被其他的程序占用了(增加了实现的难度和麻烦)。第二,如果client bind这么一个端口(3456),那么在这台电脑上,就只能运行一个client,因为同一个端口只能给一个socket使用。 所以client还是不要bind为好
connect
TCP用connect函数传入server的ip、port激发TCP三次握手与server的连接。
如果出错,会有以下几种情况
- 若TCP client没有收到SYN响应,则返回ETIMEOUT错误。
- 如果收到的SYN响应是RST,则表示连接的端口没有进程在运行,可能server没启动,返回ECONNREFUSED错误
- 若发出的SYN在中间某个路由器上引发一个ICMP错误,则认为是一种软错误,可能是按照本地系统的转发表,根本没有到达server的路径;或connect调用根本不等待就返回。
如果connect失败,则该socket不可再用,必须关闭。
UDP connect
- TCP connect会引起三次握手,UDP connect仅仅是把对端的ip port记录下来。
- TCP 只能connect一次,而UDP可以多次,用来指定一个新的ip port连接;断开和之前ip port的连接(将sin_family设置成AF_UNSPEC)。
- UDP connect可以提高效率,发送两个包间不要先断开再连接。
- UDP connect后可以使用send,write和recv,read,当然也可以使用sendto,recvfrom。
listen
listen函数仅由TCP server端调用,它做两件事
- 把一个未连接的soket转换成一个被动socket,将socket的状态由CLOSED转换成LISTEN。
- 第二个参数backlog指定内核应该为相应socket排队的最大连接数。
backlog
内核为TCP维护两个队列
- 未完成连接队列:当三次握手的时候,收到第一个SYN,发送完ACK之后,就会把这个连接放入到半连接队列中。这个队列中的socket都处于SYN_RECV状态。
- 已完成连接队列:完成三次握手的socket放在这个队列。这个队列中的socket都处于ESTABLISHED状态。
在早期linux中backlog用来指定未完成队列+已完成队列的最大值,但在现在半连接队列长度是由/proc/sys/net/ipv4/tcp_max_syn_backlog
进行设置。
连接队列的长度由我们创建socket的时候指定的backlog和/proc/sys/net/core/somaxconn(默认128)其中的较小值确定的。
如果backlog或者somaxconn设置过小,那么很多连接就无法建立,服务端会发送RST拒绝连接,这个也是很多服务器性能上不去或断开连接的原因。对于大并发的服务最好将backlog设置大一点,其实设置成65535就可以,如果内存足够的话。
SYN_FLOOD攻击
SYN_FLOOD
攻击是一种常见的DDos攻击手段,指的是client发送了SYN之后,server端返回了ACK+SYN,但是client端不发送ACK,或者直接掉线,server端就会一直保持在SYN_RCVD
状态。
如果这种client非常多,就会把半连接队列塞满,后面的连接就无法建立了,这个server的服务也就给中止了。
解决SYN_FLOOD
攻击的方法可以通过系统参数调优来解决
- 增大
tcp_max_syn_backlog
- 减小
tcp_synack_retries
,这是三次握手中,服务器回应ACK给客户端里,重试的次数。 - 启用
tcp_syncookies
,当半连接的请求数量超过了tcp_max_syn_backlog
时,内核就会启用SYN cookie机制,不再把半连接请求放到队列里,而是用SYN cookie来检验。
accept
- accept用于从已完成连接队列头返回下一个已完成连接。如果已完成队列空了,则进程进入睡眠。
- accept成功以后返回的是一个由内核生成的一个全新fd。一个服务器通常只有一个监听fd,一直存在于整个服务器的生命周期。内核会为每个已连接客户创建一个已连接socket,当这个已连接socket服务完成后,已连接socket将会被关闭。
TCP服务器常见故障
accept返回前连接中止
三次握手完成并且连接建立后,客户端TCP发送了一个RST。在服务器看来,就在该连接已由TCP排队,等着服务器进程调用accept的时候RST到达,然后,服务器调用accept函数。
服务器进程终止
客户端和服务器开始传输数据后,服务器与该客户端传输数据的子进程被杀死,于是发生:
- 服务器:
- 被杀死的子进程的所有打开描述符被关闭,并且发送一个FIN给客户端。
- SIGCHLD信号发给该子进程的父进程,并得到正确处理。
-
客户端:
没有发生特别的事,接收FIN后返回服务器一个ACK,然后继续发送数据。 -
服务器在接收客户端的ACK后,再收到客户端发送的新数据,会返回客户端一个RST,而这个RST被客户端接收后有可能会被客户端的进程所忽略,忽略RST的进程继续向接收RST的套接字写数据,内核就会发送一个SIGPIPE信号告知该进程,进程的写操作会返回EPIPE错误。该信号的默认处理是终止进程,因此进程如果不想被终止就必须捕获它。
服务器主机崩溃
服务器主机崩溃后,不会向客户端发送任何数据。根据TCP的重传机制,客户端会坚持一段时间向服务器重发数据,由于服务器主机崩溃,对这些数据无法响应,因此客户端在坚持一段时间后会放弃重传,并且进程会返回一个ETIMEOUT错误。,如果是中间路由器判定服务器不可达,则会返回EHOSTUNREACH或ENETUNREACH错误。
客户端要想检测出服务器主机崩溃可以采用两个办法:
- 向服务器主动发送数据,直到返回ETIMEOUT或者EHOSTUNREACH或者ENETUNREACH错误
- 如果不想主动发送数据也能检测出服务器主机崩溃,可以通过设置
SO_KEEPALIVE
套接字选项。
服务器主机崩溃后重启
服务器主机崩溃后重启,它的TCP丢失了TCP崩溃前的所有连接信息,因此会对收到的客户端数据响应一个RST。
如果客户端阻塞于read调用,会导致该调用返回ECONNRESET错误。
服务器主机关机
服务器主机如果被人为关机,UNIX系统的init进程会向所有进程发送SIGTERM信号,然后等待一段时间,再向所有仍在运行的进程发送SIGKILL信号。从进程角度,当接收到SIGTERM信号后,会有一段时间来处理SIGTERM信号,并且终止自己;如果超过了这段时间,进程还在运行就会被SIGKILL信号强制终止。服务器与客户端连接进程终止后,就会发生上述服务器子进程终止的情况。
keepalive
TCP通过setsockopt设置SO_KEEPALIVE来保持client和server的连接,一方会不定期发送心跳包给另一方,当一方端掉的时候,没有断掉的定时发送几次心跳包,如果间隔发送几次,对方都返回的是RST,而不是ACK,那么就释放当前链接。如果没有keepalive的机制,一旦一方断开连接却没有发送FIN给另外一方的话,那么另外一方会一直以为这个连接还是存活的,几天,几月。那么这对服务器资源的影响是很大的。
keepalive需要调节的参数有三个,在/proc/sys/net/ipv4/目录
tcp_keepalive_time
// 距离上次传送数据多少时间未收到判断为开始检测tcp_keepalive_intvl
// 检测开始每多少时间发送心跳包tcp_keepalive_probes
// 发送几次心跳包对方未响应则close连接
同时顺便对比下http的keep-alive
http的keep-alive是在发起请求时,http头带上connection:keep-alive,它主要是用于客户端告诉服务端,这个连接我还会继续使用,在使用完之后不要关闭。这个设置有两个好处
- 提高性能,因为http是短连接,如果能保持连接的话,请求多次数据之间就可以少了三次握手四次挥手的时间。
- 如果没有keep-alive,服务端发送完数据会主动断开连接,连接断开之后会进入到
TIME_WAIT
状态达2MSL之久,大量请求的话势必会对服务器性能造成很大影响。所以加上keep-alive以后对于提高服务器的性能。
参考:
close和shutdown
- close是针对文件操作,shutdown是针对socket操作,sockt是一个文件,但文件不一定是socket
- close是把文件描述符引用计数减1,仅在该计数为0时关闭。shutdown不管引用计数直接激发TCP连接终止。
- close终止读和写两个方向的数据传送,shutdown可以只关闭一端。如下图所示
select和epoll
IO模型
阻塞式IO
默认,所有socket都是阻塞的。
当用户进程调用了recvfrom这个系统调用,内核就开始了IO的第一个阶段:准备数据(对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候内核就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。当内核一直等到数据准备好了,它就会将数据从内核中拷贝到用户内存,然后内核返回结果,用户进程才解除block的状态,重新运行起来。
阻塞式IO的特点就是在IO执行的两个阶段都被阻塞了
非阻塞 I/O
当用户进程发出read操作时,如果内核中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦内核中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。
非阻塞IO的特点是用户进程需要不断的主动询问内核数据好了没有
I/O 多路复用
I/O 多路复用就是我们说的select,poll,epoll,select/epoll的好处就在于单个进程就可以同时处理多个网络连接的IO。它的基本原理就是select,poll,epoll会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。
I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。
异步IO
用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从内核的角度,当它收到一个异步读之后,首先它会立刻返回,所以不会对用户进程产生任何阻塞。然后,内核会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,内核会给用户进程发送一个信号,告诉它read操作完成了。
I/O 多路复用
I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
select
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。调用后select函数会阻塞,直到有描述副就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以 通过遍历fdset,来找到就绪的描述符。
select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。select的一 个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过修改FD_SETSIZE
宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低。
poll
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
不同与select使用三个位图来表示三个fdset的方式,poll使用一个 pollfd的指针实现。
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events to watch */
short revents; /* returned events witnessed */
};
pollfd结构包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式。同时,pollfd并没有最大数量限制(但是数量过大后性能也是会下降)。 和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。
从上面看,select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。
epoll
epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
epoll操作过程需要三个接口,分别如下:
int epoll_create(int size);//创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
int epoll_create(int size);
创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值,参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议。
当创建好epoll句柄后,它就会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
函数是对指定描述符fd执行op操作。
- epfd:是epoll_create()的返回值。
- op:表示op操作,用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加、删除和修改对fd的监听事件。
- fd:是需要监听的fd(文件描述符)
- epoll_event:是告诉内核需要监听什么事,struct epoll_event结构如下:
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
events可以是以下几个宏的集合:
- EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
- EPOLLOUT:表示对应的文件描述符可以写;
- EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
- EPOLLERR:表示对应的文件描述符发生错误;
- EPOLLHUP:表示对应的文件描述符被挂断;
- EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
- EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待epfd上的io事件,最多返回maxevents个事件。
参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。
epoll工作模式
LT模式
LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket.在这种模式下,当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。
也就是说内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的。
ET模式
ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。
也就是说当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知。
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
epoll总结
在 select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait() 时便得到通知。(此处去掉了遍历文件描述符,而是通过监听回调的的机制。这正是epoll的魅力所在。)
epoll的优点主要是一下几个方面:
-
监视的描述符数量不受限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以
cat /proc/sys/fs/file_max
察看,一般来说这个数目和系统内存关系很大。select的最大缺点就是进程打开的fd是有数量限制的。这对于连接数量比较大的服务器来说根本不能满足。虽然也可以选择多进程的解决方案( Apache就是这样实现的),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完美的方案。 -
IO的效率不会随着监视fd的数量的增长而下降。epoll不同于select和poll轮询的方式,而是通过每个fd定义的回调函数来实现的。只有就绪的fd才会执行回调函数。
-
表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。
select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善。
参考