Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

4.5 编程中的IO操作

我们在编程中会大量用到IO操作,比如读写文件、发送网络请求、访问数据库等,IO操作往往是程序性能的瓶颈。理解IO操作的底层原理,选择合适的IO模型,对提升程序性能至关重要。

IO操作的底层流程

我们以读取磁盘文件为例,看一下一次IO操作的完整流程:

  1. 应用程序发起read系统调用,请求读取文件内容
  2. 操作系统检查内核缓冲区中是否有需要的数据:
    • 如果有,直接把内核缓冲区中的数据拷贝到应用程序的缓冲区,调用返回
    • 如果没有,操作系统发起磁盘IO请求,告诉磁盘控制器要读取的数据位置
  3. 磁盘控制器通过DMA方式将磁盘上的数据拷贝到内核缓冲区
  4. 操作系统将内核缓冲区中的数据拷贝到应用程序的缓冲区
  5. 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。

思考问题

  1. 一次文件读取操作经过了哪些步骤?为什么内核缓冲区能提升IO性能?
  2. IO多路复用模型相比阻塞IO模型,为什么能支持更高的并发?
  3. 什么是零拷贝技术?它是怎么提升IO性能的?
  4. 你在开发中遇到过哪些IO相关的性能问题?是怎么优化的?