在内核中,为每个socket维护两个队列,一个是已建立连接的队列,也就是完成了三次握手,处于established状态,一个是还没有完全建立连接的队列,处于sync_rcvd状态。
在linux中,socket是一个文件,有对应的文件描述符,网络读写都是通过这个文件描述符的。这个文件描述符有一个对应的socket结构,包含两个队列,一个是发送队列,一个是接收队列。
一个socket由五元组(如果不考虑domain,就是四元组):(本机ip, 本机端口,对端ip, 对端端口,domain), 其中对于一台服务器来说,本机IP, 本机端口,domain都是固定的。这样连接数由对端ip,对端端口数限制,也就是2^32*2^16=2^48。但是还受限于服务器能创建的句柄数,可以通过ulimit来修改,另一个限制是内存,因为维护每个socket是需要内存资源的。
I/O多路复用可以让我们的应用程序同时处理多个I/O, 有I/O事件的时候通知应用程序去处理, 不需要应用程序一直等待某个I/O事件。有了I/O多路复用,应用程序不必要为每个socket链接开一个线程或者进程单独处理,这样就可以突破了C10K问题。
select将需要监听的fd列表拷贝到内核空间,如果有读写事件或者timeout了,应用层收到通知,然后遍历各个监听的fd,找到有事件的fd。
select的不足是包括,linux下最多能监听的事件数是1024,并且需要拷贝fd列表到内核空间,需要遍历fd列表才能找到具体事件的fd。
poll对select做了一个优化,突破了最大监听数1024的限制,但是没有解决另外两个性能问题。
epoll是一个终极解决方案,通过mmap, 将需要监听的fd列表的内存空间映射成与内核空间同一份,避免用户态到内核态的拷贝。
epoll通过红黑树来组织fd集合,当有事件发生时,将有事件的fd列表返回给应用程序。这样应用程序只需要遍历有事件的fd。
边缘触发(ET)与条件触发(LT):边缘触发的意思是,只有第一次满足条件的时候才触发,之后就不会再传递同样的时间了。水平触发则是不断的把这个事件,传递给应用程序,知道应用程序处理这个事件了。
这种方式最为简单,服务端接收每个连接,都fork一个独立的进程来处理这个链接的读写事件,各个链接互不影响。但是缺点比较明显,效率不高,扩展性差,资源占用率高。
for (;;) {
fd <- accept()
process <- fork()
process_run(fd)
}
和上面一个模型类似,只是把进程改成了线程,减少了资源占用。
for (;;) {
fd <- accept()
thread <- pthread_create()
thread_run(td)
}
为了避免创建过多的线程数据,可以采用线程池的方式来处理。
for (;;) {
fd <- accept()
push_queue(fd)
}
for (;;) {
fd <- get_from_queue()
thread <- thread_pool.get()
thread_run(fd)
}
可以通过select/poll这样的I/O多路复用技术,来实现事件处理, 等待有事件的时候才唤醒处理
for (;;) {
poller.disptch()
for fd in registered_fdset {
if (is_readable(fd)) {
handle_read(fd)
} else if (is_writeable(fd)) {
handle_write(fd)
}
}
}
可以采用更高效的epoll,直接遍历有事件的fdset,就变成如下
for (;;) {
poller.disptch()
for fd in active_event_set {
if (is_readable(fd)) {
handle_read(fd)
} else if (is_writeable(fd)) {
handle_write(fd)
}
}
}
引入多线程,可以利用cpu的多核能力,采用I/O多路复用和多线程来处理网络事件,这种模式也叫Recator模式。通常在实现的时候,一个主Recator(main reactor)用一个线程来监听网络连接,并接收socket,当接收到一个socket, 把socket交给某个子Reactor(sub reactor )去处理,有多个子Reactor, 每个子reactor对应一个线程,通过I/O多路复用处理自己所负责的网络连接的读写事件,以读取完整的请求包和写入完整的发送包。这里只是处理网络读写,业务逻辑往往也是交给独立的线程去处理,通常是一个线程池,网络读写的sub reactor和业务逻辑直接通过队列来解耦。线程池里的线程读取队列,并做业务逻辑处理和编解码。如下图: