浅谈IO这件事
Nginx底层是用的什么,是IO多路复用,是Epoll。
Redis底层是用的什么,是IO多路复用,是Epoll。
Python的tornado框架底层是用的什么,是IO多路复用,是Epoll。
要理解什么是IO多路复用,什么是Epoll?就要先说什么是IO,计算机底层的IO是怎样实现的。
先说计算机启动时的状态:
计算机启动时,内存里存放的是系统的kernel和其他应用程序
内核是用来管理硬件的,为了保护操作系统的,不被破坏,内核在启动的时候会注册GDT(全局描述符表),GDT会把内存空间划分为用户空间和内核空间,用户程序不能直接访问内核
用户程序IO调用内核是有成本的,成本有哪些呢,内核提供了system call(系统调用),用户程序通过软中断的方式调用syscall。程序执行到系统调用时,首先使用类似int 80的软中断指令(因为软中断是由程序给出的指令,不需要使用中断控制器),保存现场,去系统调用,在内核态执行,然后恢复现场,每个进程都会有2个栈,内核栈和用户栈,当int中断执行时会由用户态栈转向内核态栈。这些是基本的开销,系统调用时需要进行栈的切换,而且内核不信任用户,需要进行额外的检查。
写一个程序来看一下网络IO
trysocket.py
# /usr/bin/python # -*- coding:utf-8 -*- import socket import threading host = '127.0.0.1' port = 13579 def msg_handel(conn): while True: buf = conn.recv(1024) print(buf) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.bind((host, port)) sock.listen(5) while True: conn, addr = sock.accept() thread = threading.Thread(target=msg_handel, args=(conn,)) thread.setDaemon(True) thread.start()
使用strace命令来跟踪进程执行时的系统调用和所接收的信号,把信息存储到logsocket文件中
可以看到文件目录下面有一个日志文件了
现在能看到开启了一个tcp服务,监听13579端口,进程号5402
Linux一切皆文件,去到/proc/5402/task目录下,能看到5402进程开启的线程
进到线程里去看文件描述符/proc/5402/task/5402/fd
可以看到有个符号为3的文件描述符,socket最终是要对应到IO的,建立连接肯定是要传输数据
这个时候logsocket.5402文件中的信息是
可以看到阻塞到accept这个位置,这是一个内核的系统调用,3就是上面在/proc/5402/task/5402/fd看到的文件描述符
然后,开始建立连接,使用nc命令建立tcp连接
看到logsocket.5402文件中也多了一些信息,有客户端端口,ip,建立了一个新的连接,文件描述符是4,clone了一个线程,线程id是8935
去/proc/5402/task下看是否多了个线程8935
再查看端口,发现已经多了2个记录,状态是ESTABLISHED,这个表示3次握手建立完成,127.0.0.1:44112表示客户端的地址和端口
这个时候去/proc/5402/task/5402/fd,果然多了一个4的文件描述符
现在再建立一个连接,使用nc命令建立tcp连接
再查看端口,又多了2个新记录,客户端端口44902,127.0.0.1:44902表示客户端的地址和端口
这个时候去/proc/5402/task/5402/fd,又多了一个5的文件描述符
然后查看logsocket.5402日志,clone信息到线程11050中,然后阻塞在系统调用accept的地方,等待新连接
在这时以及有3个日志文件了
我们看一下2个nc连接的tcp日志文件
发现2个文件描述符分别为4,5,都阻塞在系统调用recvfrom,等待接收消息
下面开始从第二个nc连接发送一条消息,”hello world”,回车发送
日志文件开始有新日志,recvfrom接收到消息,返回字节长度12,系统调用write写入标准输出,然后阻塞到系统调用recvfrom上,继续等待消息
由于写到了标准输出上,所以在strace上能看到打印hello world
上面这些是通过BIO开启多线程处理连接的方式,每个线程对应一个client连接,通过api调用syscall,这里面有一些问题:
创建线程多,创建线程需要系统调用,需要软中断
线程需要消耗资源:内存(栈共享,堆独立)
线程创建,销毁,上下文切换(context switch)需要内核空间和用户空间的互相拷贝,都会影响cpu调度资源
还有一个更严重的问题,阻塞(不阻塞就不用开这么多线程了)
总结BIO缺点:
阻塞IO
弹性伸缩差
多线程资源消耗大
给BIO贴个图
针对BIO的缺点,发展出来另一种IO 非阻塞IO(NIO)
# /usr/bin/python # -*- coding:utf-8 -*- import socket host = '127.0.0.1' port = 13579 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setblocking(False) sock.bind((host, port)) sock.listen(5) conn_list = [] while True: try: conn, addr = sock.accept() conn.setblocking(False) conn_list.append((conn, addr)) except BlockingIOError as err: # setblocking(False),accept不阻塞,如果没有连接进来会触发BlockingIOError,所以捕获异常 pass for _conn, _addr in conn_list: try: buf = _conn.recv(1024) if buf: print(buf) except BlockingIOError: pass
通过sock.setblocking(False) 设置为非阻塞socket,pass掉无连接时的BlockingIOError,设置每个conn为noblock,不用开启线程,每次循环所有连接,用recv接收所有的信息
通过strace命令看这个python程序都做了什么
打开日志,可以看到
Ioctl可以看到给文件描述符3设置非阻塞io(FIONBIO是非阻塞标志,1表示设置为非阻塞),后面的accept由于非阻塞,也没有连接所以返回-1
看一下NIO逻辑
NIO对BIO的改进:
非阻塞IO(提供了一个统一管理conn的管理器)
弹性伸缩(服务端不再是通过多线程处理conn)
单线程节省资源
这个也有缺点:
当连接数过大的时候,比如C10K问题,每次循环都要遍历所有的连接,看一下有没有数据过来,假如10k连接里只有1个连接发来数据了,这样有9999次遍历的系统调用就浪费了,这个时间复杂度是O(N)
那怎么解决这个问题呢,从用户态是解决不了的,所以,内核做了优化
man 2 select
内核增加了select,I/O多路复用
用一个selector选择器注册fd,内核中对n个连接fd做遍历,时间复杂度O(N),减少了系统调用的次数,当内核遍历过程中发现有的fd有数据了,用户就调用recvfrom进行对读写,时间复杂度O(m),m是可读写描述符的个数
这个时候连接数过多时,内核循环的时间就会很长
为了改进这个问题,内核出现了epoll
Epoll有3个主要的方法
epoll_create
创建一个epoll的句柄,size用来告诉内核这个监听的数目最大值
epoll_ctl
事件注册
epoll_wait
等待事件变更
一般步骤为
1,创建1个epoll对象
2,告诉epoll对象,在指定的socket上监听指定的事件
3,询问epoll对象,从上次查询以来,哪些socket发生了哪些指定的事件
4,在这些socket上执行一些操作
5,告诉epoll对象,修改socket列表和(或)事件,并监控
6,重复步骤3-5,直到完成
7,销毁epoll对象
python中使用epoll的方式
#代码来源http://scotdoyle.com/python-epoll-howto.html import socket, select EOL1 = b'\n\n' EOL2 = b'\n\r\n' response = b'HTTP/1.0 200 OK\r\nDate: Mon, 1 Jan 1996 01:01:01 GMT\r\n' response += b'Content-Type: text/plain\r\nContent-Length: 13\r\n\r\n' response += b'Hello, world!' serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) serversocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) serversocket.bind(('0.0.0.0', 8080)) serversocket.listen(1) serversocket.setblocking(0) epoll = select.epoll() epoll.register(serversocket.fileno(), select.EPOLLIN) try: connections = {}; requests = {}; responses = {} while True: events = epoll.poll(1) for fileno, event in events: if fileno == serversocket.fileno(): connection, address = serversocket.accept() connection.setblocking(0) epoll.register(connection.fileno(), select.EPOLLIN) connections[connection.fileno()] = connection requests[connection.fileno()] = b'' responses[connection.fileno()] = response elif event & select.EPOLLIN: requests[fileno] += connections[fileno].recv(1024) if EOL1 in requests[fileno] or EOL2 in requests[fileno]: epoll.modify(fileno, select.EPOLLOUT) print('-' * 40 + '\n' + requests[fileno].decode()[:-2]) elif event & select.EPOLLOUT: byteswritten = connections[fileno].send(responses[fileno]) responses[fileno] = responses[fileno][byteswritten:] if len(responses[fileno]) == 0: epoll.modify(fileno, 0) connections[fileno].shutdown(socket.SHUT_RDWR) elif event & select.EPOLLHUP: epoll.unregister(fileno) connections[fileno].close() del connections[fileno] finally: epoll.unregister(serversocket.fileno()) epoll.close() serversocket.close()
nginx和redis是如何使用epoll的?
先看nginx
使用strace命令看一下nginx是如何工作的
在当前目录下生成有3个log文件
看一下nginx进程
一个master和一个worker,master进程号477,work进程号478,上面的日志476是什么?
打开nginxtest.476
上面都说过这些系统调用,就不重复说了,但是在这里面没有epoll,跳到文件末尾
发现476 clone了一个477后exit了,然后我们看477
477是master进程,不负责干活,只负责开启worker进程,clone了一个478,应该就是worker进程了,去看478
一定会有一个epoll_create,等到文件描述符9,内核开辟一块空间,调用epoll_ctl,把11号文件描述符add到9号文件描述符,在epoll_wait等待事件,阻塞的
nginx是这样一个流程使用epoll的
同样的方式去看redis
出现3个日志,现在看日志,3840数据很大,先看它
调用epoll_create得到文件描述符3,内核开辟空间,然后4是IPV6的不用管他,后面bind 文件描述符5,到6379端口,下面调用epoll_ctl把4,5都add到3里面, 继续看后面,发现epoll_wait没有阻塞,它轮询了,为什么呢
redis6已经改变为多线程了,但这里redis版本是redis3.2.12,redis还是单线程版本,这个线程需要做其他的事情,例如创建线程去做如LRU,LFU这样的淘汰过滤,还要做AOF重写,RDB镜像这样的,所以,在一个轮询里,既有IO又做其他事情
redis6的逻辑还有优化
redis6在多线程的时候多加了IO Threads,专门负责IO,计算放在主线程里
至此,IO这件事讲的差不多了,才疏学浅,如有错误,还请指正。