深入理解异步IO的底层逻辑——IO多路复用(select、poll、epoll)

前言

  在前面两篇文章《gevent与协程》《asyncio与协程》,讨论了有关协程异步编程方面的内容,从代码层面和基本的demo可以大致理解协程的工作方式。如果要深入理解为何单线程基于事件的驱动可以在“低能耗”的条件下达到高性能的IO服务,则要研究Linux底层实现原理——IO多路复用,而理解IO多路复用的前提是对文件描述符有较为深入的理解,因此本文把文件描述符和IO多路复用放在同一篇文章里,形成全局的体系化认知,这就是本文讨论的内容。

1、理解文件描述符
1.1 基本概念

  在Linux中,一切皆文件,而理解文件描述符才能理解“一切皆文件”的真实含义,IO多路复用的select、poll和epoll机制正是通过操作文件描述符集合来处理IO事件。
含义,这里引用百度的介绍:

  文件描述符是一个索引号,是一个非负整数,它指向普通的文件或者I/O设备,它是连接用户空间和内核空间纽带。在linux系统上内核(kernel)利用文件描述符(file descriptor)来访问文件。打开现存文件或新建文件时,内核会返回一个文件描述符。读写文件也需要使用文件描述符来指定待读写的文件。(在Windows系统上,文件描述符被称作文件句柄)

当你看完本篇内容后,再回它这段解释,总结得真到位!在后面会给出为何文件描述符是一个非负整数,而不是其他更为复杂数据结构呢(例如hash map、list、链表等)?

1.2 打开一个文件

  当某个进程打开一个已有文件或创建一个新文件时,内核向该进程返回一个文件描述符(一个非负整数)。
(在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIXLinux这样的操作系统。)
这里以打开的iPython shell进程调用os.open为例,OS是Centos7.5

In [1]: import os
In [6]: fd = os.open( "/opt/test.txt", os.O_RDWR|os.O_CREAT) # os.O_RDWR读写模式打开,os.O_CREAT若文件不存在则创建               
In [7]: fd                                                                             
Out[7]: 17 # 这个17就是file descriptor

在Python里面,os.open方法返回文件描述符是更为底层API,而open方法是返回python文件对象,是更贴近用户的API。

在linux系统上查看以上iPython进程打开的所有文件描述符示例:(这里就是一个文件描述表的大致形式,每一个文件描述符指向一个文件或者设备)

[root@nn opt]# ll /proc/11622/fd #11622为ipython的shell进程
total 0
lrwx------ 1 root root 64 **** 16:43 0 -> /dev/pts/0
lrwx------ 1 root root 64 **** 16:43 1 -> /dev/pts/0
lr-x------ 1 root root 64 **** 16:43 10 -> pipe:[41268]
l-wx------ 1 root root 64 **** 16:43 11 -> pipe:[41268]
lrwx------ 1 root root 64 **** 16:43 12 -> anon_inode:[eventpoll]
lrwx------ 1 root root 64 **** 16:43 13 -> socket:[41269]
lrwx------ 1 root root 64 **** 16:43 14 -> socket:[41270]
lr-x------ 1 root root 64 **** 16:43 15 -> pipe:[41271]
l-wx------ 1 root root 64 **** 16:43 16 -> pipe:[41271]
l-wx------ 1 root root 64 **** 16:43 17 -> /opt/test.txt
lrwx------ 1 root root 64 **** 16:43 18 -> /opt/test.txt
lrwx------ 1 root root 64 **** 16:43 19 -> /opt/test.txt
lrwx------ 1 root root 64 **** 16:43 2 -> /dev/pts/0
lrwx------ 1 root root 64 **** 16:43 20 -> anon_inode:[eventpoll]
l-wx------ 1 root root 64 **** 16:43 3 -> /dev/null
lrwx------ 1 root root 64 **** 16:43 4 -> /root/.ipython/profile_default/history.sqlite
lrwx------ 1 root root 64 **** 16:43 5 -> /root/.ipython/profile_default/history.sqlite
lrwx------ 1 root root 64 **** 16:43 6 -> anon_inode:[eventpoll]
lrwx------ 1 root root 64 **** 16:43 7 -> socket:[41266]
lrwx------ 1 root root 64 **** 16:43 8 -> socket:[41267]
lrwx------ 1 root root 64 **** 16:43 9 -> anon_inode:[eventpoll]

因为在ipython里面,fd = os.open( "/opt/test.txt", os.O_RDWR) 运行3次,也就文件/opt/test.txt打开3次,所以返回个文件描述符:17、18、19(从这里说明,同一进程可以同一时刻打开同一文件多次)

11622进程号指向当前iPython shell,查看它打开的文件描述符18,指向被打开文件:/opt/test.txt

[root@nn opt]# ll /proc/11622/fd/18 
lrwx------ 1 root root 64 **** 16:43 /proc/11622/fd/18 -> /opt/test.txt

关闭文件描述符就关闭了所打开的文件

In [14]: os.close(19)                                                                  
In [15]: os.close(18)                                                                  
In [16]: os.close(17)
1.3 对文件描述符进行读写

读:通过给定文件描述符读文件内容

"""
# os.read()方法的docstring
os.read()
Signature: os.read(fd, length, /)
Docstring: Read from a file descriptor.  Returns a bytes object.
Type:      builtin_function_or_method
"""
import os
fd=os.open('/opt/test.txt',os.O_RDWR|os.O_CREAT)
data=os.read(fd,64) #指定读文件前64byte内容 
print(data) # b'foo\nbar\n\n'

写:通过给定文件描述符将数据写入到文件

"""
# os.read()方法的docstring
Signature: os.write(fd, data, /)
Docstring: Write a bytes object to a file descriptor.
Type:      builtin_function_or_method
"""
import os
fd=os.open('/opt/test.txt',os.O_RDWR|os.O_CREAT)
byte_nums=os.write(fd,b'save data by file descriptor directly \n') # 注意要写入byte类型的数据
print(byte_nums) # 返回写入byte字符串长度(字符个数)

了解基本调用底层的os读写文件描述符的方法,也可以封装出一个类似内建open方法的定制myopen类。

1.4 通过管道打开文件描述符

也可以通过管道pipe方法(创建一个无名管道)同时打开一个读文件描述符以及一个写文件描述符。(有关管道的定义和理解本文不再累赘,可参考其他博文。)

import os
fd_read,fd_write=os.pipe()
print('fd_read:',fd_read,'fd_write:',fd_write) #系统返回两个整数3、4, fd_read: 3 fd_write: 4
os.write(fd_write,b'foo') # 向管道的写端写入数据
os.read(fd_read,64) # 从管道的读端读取数据

创建管道时总是返回相邻的两个整数,因为stderr为2,故之后创建的文件描述符只能从3开始,示意图如下:
在这里插入图片描述
如果尝试向管道另外一端的fd_write描述符读取数据,就会报错,所以对于管道,读数据只能在读文件描述符上读操作,写入数据只能在写文件描述符操作。

os.read(fd_write,64)
OSError: [Errno 9] Bad file descriptor

如果已经把fd_read读取完好后,此时管道为空,若再读取该管道,进程会被阻塞,因为写管道端没有数据写入,这是管道的性质之一——数据一旦被读走,便不在管道中存在,若此时还继续向读端反复读取,则进程会被阻塞。

注意写入管道的字符个数是有限制的,当超过管道容量时,写入操作被阻塞,可以通过以下方法精确策略出

import os
def get_pipe_capacity(size):
    fd_read,fd_write=os.pipe()
    total=0
    print("start to count")
    for i in range(1,size+1):
        os.write(fd_write,b'a')
        total=i
        
    print("end to count,total bytes:",total)

get_pipe_capacity(64*1024)
输出:
start to count
end to count,total bytes: 65536

往管道写入64*1024 大小的byte时,管道写端未发生阻塞,当把写入的byte数改为:写入64*1024+1时,写入操作被阻塞了

get_pipe_capacity(64*1024+1)
输出:
start to count # 执行流被阻塞,无后续输出。

通过该方法可以精确测量出pipe默认容量为64KB。
看到这部内容,是否有人联想到在使用subprocess执行某些cmd命令后,一直卡在读取输出上?
常见用法:

p= subprocess.Popen(your_cmd, shell= True, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) # 问题出现在:标准输出使用了管道,而管道有容量限制,当命令返回的数据大小超过管道64KB时,执行流卡在这里
bytes_result, err = p.communicate(timeout=1)
str_result = str(bytes_result, encoding="utf-8")
if p.returncode != 0:
    pass
if not str_result:
    return False
else:
    return True

问题出现在:subprocess.Popen标准输出使用了管道,而管道有容量限制,当你的your_cmd返回的数据大小超过管道64KB时(stdout获取返回数据,用了管道存放),执行流卡在subprocess.Popen这里,其实进程阻塞了。
既然知道管道有容量限制,那么可以将stdout定向到本地文件系统,那么输出的数据就存放到容量更大的文件,建议使用临时文件作为重定向输出,如下所示:

def stdout_by_tempfile():
    # SpooledTemporaryFile也是一个普通文件对象,当然支持with协议(它的源码实现了__enter__和__exit__方法)
    with tempfile.SpooledTemporaryFile(buffering=1*1024) as tf:
    """创建一个临时文件对象,注意这个bufferfing不是限制只能存储1024字节的数据,而是输出内容超过1024字节后,自动将输出的数据缓存到临时文件里"""
        try:
            fd = tf.fileno() # 返回文件描述符,这个文件描述符不再指向管道,而是指向某个临时文件
            p = subprocess.Popen(your_cmd,stdin=fd,stdout=fd,stderr=fd,shell=True) # 将stdout输出的内容定向到文件描述符指向的文件
            p.communicate(timeout=5) # 指定输出超时时间
            tf
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值