4.5 编程中的IO操作
我们在编程中会大量用到IO操作,比如读写文件、发送网络请求、访问数据库等,IO操作往往是程序性能的瓶颈。理解IO操作的底层原理,选择合适的IO模型,对提升程序性能至关重要。
IO操作的底层流程
我们以读取磁盘文件为例,看一下一次IO操作的完整流程:
- 应用程序发起read系统调用,请求读取文件内容
- 操作系统检查内核缓冲区中是否有需要的数据:
- 如果有,直接把内核缓冲区中的数据拷贝到应用程序的缓冲区,调用返回
- 如果没有,操作系统发起磁盘IO请求,告诉磁盘控制器要读取的数据位置
- 磁盘控制器通过DMA方式将磁盘上的数据拷贝到内核缓冲区
- 操作系统将内核缓冲区中的数据拷贝到应用程序的缓冲区
- read系统调用返回,应用程序拿到数据继续执行
可以看到,一次IO操作涉及两次数据拷贝:磁盘→内核缓冲区,内核缓冲区→应用程序缓冲区,还有操作系统和硬件的交互。
内核缓冲区的作用
操作系统会在内存中开辟一块内核缓冲区(Page Cache),缓存最近访问过的磁盘数据,减少磁盘IO次数:
- 读操作:如果要读的数据已经在Page Cache中,就不需要访问磁盘,直接从内存读取,速度快很多
- 写操作:应用程序写数据时,先写到Page Cache中,操作系统会在合适的时机批量刷到磁盘上,提升写性能
Page Cache的存在大大提升了IO性能,所以我们经常会看到第一次读文件很慢,第二次读就很快,因为数据已经被缓存到内存里了。
常见IO模型
我们在4.1节已经介绍了IO模型的分类,现在详细讲解每个模型的特点和适用场景。
1. 阻塞IO(BIO)
- 工作流程:应用程序发起IO调用后,线程被阻塞挂起,直到IO操作完成才返回继续执行
- 示例:Java的传统IO、Python的默认文件操作都是阻塞IO
# 阻塞IO示例,read调用会阻塞直到文件读取完成 with open('file.txt', 'r') as f: content = f.read() # 阻塞在这里 print(content) - 优点:编程模型简单,容易理解和开发
- 缺点:线程利用率低,高并发场景下需要大量线程,内存和上下文切换开销大
- 适用场景:并发量不高的场景,或者逻辑简单的程序
2. 非阻塞IO
- 工作流程:应用程序发起IO调用后,立即返回,如果数据还没准备好,返回错误,应用程序可以轮询检查数据是否准备好
- 示例:
import os f = os.open('file.txt', os.O_RDONLY | os.O_NONBLOCK) while True: try: content = os.read(f, 1024) break except BlockingIOError: # 数据还没准备好,做其他事情 print('waiting...') - 优点:线程不会阻塞,可以做其他事情,利用率高
- 缺点:轮询会消耗大量CPU资源,效率低
- 适用场景:很少单独使用,一般和IO多路复用结合使用
3. IO多路复用(IO Multiplexing)
- 工作原理:用一个线程监听多个IO请求的状态,当某个请求准备好后,再处理这个请求,不需要为每个请求开一个线程
- 常见实现:
- select:跨平台,监听的文件描述符数量有限(默认1024),性能随数量增加而下降
- poll:和select类似,但没有数量限制,性能还是随数量增加而下降
- epoll(Linux)/kqueue(BSD/macOS)/IOCP(Windows):性能高,没有数量限制,事件通知机制,是高性能服务器的首选
- 示例(Python selectors):
import selectors import socket sel = selectors.DefaultSelector() def accept(sock): conn, addr = sock.accept() conn.setblocking(False) sel.register(conn, selectors.EVENT_READ, read) def read(conn): data = conn.recv(1024) if data: print(f'received {data}') conn.send(data) else: sel.unregister(conn) conn.close() sock = socket.socket() sock.bind(('localhost', 8080)) sock.listen(100) sock.setblocking(False) sel.register(sock, selectors.EVENT_READ, accept) while True: events = sel.select() for key, mask in events: callback = key.data callback(key.fileobj) - 优点:线程利用率极高,一个线程可以处理成千上万的连接,内存和上下文切换开销很小
- 缺点:编程复杂度比阻塞IO高,需要处理事件回调
- 适用场景:高并发网络服务器,是现在的主流方案,Nginx、Redis、Node.js等都用的是IO多路复用模型
4. 异步IO(AIO)
- 工作原理:应用程序发起IO请求后立即返回,操作系统完成整个IO操作(包括数据从磁盘/网卡拷贝到应用程序缓冲区)后,通知应用程序处理
- 和IO多路复用的区别:IO多路复用是通知你IO准备好了,你需要自己去读数据;异步IO是操作系统已经帮你把数据读好了,直接通知你用
- 优点:完全不阻塞线程,效率最高
- 缺点:编程复杂度高,支持的平台有限,Linux下的AIO实现不完善,Windows下的IOCP是成熟的异步IO实现
- 适用场景:对性能要求极高的场景,现在还不是很普及
IO密集型应用的优化方法
对于IO密集型的应用,比如Web服务器、数据库、文件服务器等,IO性能往往是瓶颈,这里分享一些优化方法:
1. 选择合适的IO模型
- 并发量不高:用阻塞IO,开发简单
- 高并发场景:用IO多路复用,是目前的主流方案
- 尽可能用成熟的网络框架,不要自己从头实现IO模型,比如Netty(Java)、libuv(C++)、Tornado(Python)等,这些框架已经把IO模型优化得很好了
2. 优化IO模式
- 批量IO:合并小的IO请求为大的IO请求,减少IO次数,比如批量写入、批量读取
- 顺序IO:尽量用顺序IO代替随机IO,特别是对于HDD,顺序IO的性能比随机IO高很多,比如数据库的WAL日志就是顺序写
- 异步IO:把同步IO改成异步IO,不要阻塞线程,提升并发能力
- 零拷贝(Zero Copy):减少数据拷贝的次数,比如Linux的sendfile系统调用,可以直接把文件数据发送到网络,不需要经过应用程序缓冲区,减少两次数据拷贝,大大提升文件传输性能,Nginx、Apache等都用了零拷贝技术
3. 利用缓存
- Page Cache:充分利用操作系统的Page Cache,热点数据尽量缓存到内存中,减少磁盘IO
- 应用层缓存:在应用层加一层缓存(Redis、Memcached等),缓存热点数据,减少对后端存储和数据库的访问
- 缓存预热:启动时把热点数据加载到缓存中,避免冷启动时大量请求穿透到数据库
4. 优化存储
- 用SSD代替HDD:对于随机IO多的场景,SSD的性能是HDD的上百倍,能极大提升性能
- RAID优化:根据场景选择合适的RAID级别,RAID0提升性能,RAID1提升可靠性,RAID10兼顾性能和可靠性
- 分布式存储:大规模场景下用分布式存储,把数据分散到多个节点,提升整体IO性能和容量
5. 网络IO优化
- 减少网络请求次数:合并请求,批量操作,减少RTT(往返时间)的影响
- 压缩数据:传输前压缩数据,减少传输的数据量
- 长连接复用:用长连接代替短连接,减少三次握手和四次挥手的开销
- 合适的缓冲区大小:设置合适的socket缓冲区大小,提升网络传输效率
- CDN加速:静态资源放到CDN上,让用户从最近的节点访问,降低延迟
常见的IO坑点
1. 忽略IO异常
IO操作很容易出现异常(比如磁盘满了、网络断了、文件不存在等),一定要处理IO异常,不要忽略错误,否则可能会导致数据丢失或者程序崩溃。
2. 资源泄漏
IO操作使用的资源(文件描述符、socket连接等)一定要记得关闭,最好用try-with-resources或者with语句,自动关闭资源,避免资源泄漏。
错误示例:
# 不好的写法,如果中间出现异常,文件不会关闭
f = open('file.txt', 'r')
content = f.read()
# 处理逻辑,如果这里抛出异常,f.close()不会执行
f.close()
正确示例:
# 好的写法,with语句会自动关闭文件
with open('file.txt', 'r') as f:
content = f.read()
3. 缓冲区问题
- 写入操作通常是写到缓冲区就返回了,不是立即写到磁盘/网络上,如果需要确保数据持久化,要调用flush或者fsync同步到磁盘
- 缓冲区大小设置要合适,太小会导致频繁IO,太大会浪费内存
4. 阻塞IO导致程序卡顿
UI程序中不要在主线程做IO操作,会导致界面卡顿,应该把IO操作放到后台线程执行,完成后再通知主线程更新UI。
思考问题
- 一次文件读取操作经过了哪些步骤?为什么内核缓冲区能提升IO性能?
- IO多路复用模型相比阻塞IO模型,为什么能支持更高的并发?
- 什么是零拷贝技术?它是怎么提升IO性能的?
- 你在开发中遇到过哪些IO相关的性能问题?是怎么优化的?