浅谈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这件事讲的差不多了,才疏学浅,如有错误,还请指正。 
 

 

posted @ 2020-04-25 16:35  Andy冉明  阅读(455)  评论(1编辑  收藏  举报