Python os.sendfile 零拷贝复制大文件实战

举报
yd_250998817 发表于 2026/11/05 17:54:07 2026/11/05
【摘要】 在 Linux 上用 Python 的 os.sendfile 复制大文件可减少用户态数据搬运;本文给出支持指定偏移与长度、处理部分发送和 EINTR、失败回退 shutil.copyfileobj 的最小实现,并说明 strace 验证与边界。

在 Linux 上,用 Python 的 read/write 循环复制大文件时,数据往往要先进入用户态缓冲区,再写回内核。对于备份、日志归档、镜像层搬运等场景,如果源和目标都是本地文件,可以尝试 os.sendfile:它把复制工作交给内核完成,减少用户态缓冲区参与。需要强调,所谓零拷贝是相对用户态缓冲区而言,内核内部仍会进行必要的数据搬运,并不等于完全没有拷贝。

问题场景

需求是复制大文件的一段范围:给定源路径、目标路径、源文件偏移 offset 和目标长度 length。复制过程中要处理两类常见情况:

  1. os.sendfile 可能只发送一部分字节,必须循环直到剩余长度为零。
  2. 系统调用可能被信号中断,需要识别 EINTR 并重试。

另外,sendfile 并非在所有内核、文件系统和文件类型上都可用。如果出现 ENOSYS、EINVAL、EXDEV、EOPNOTSUPP 等“不支持”类错误,应回退到 shutil.copyfileobj,而不是直接失败。回退时要根据已经复制的字节数续传,避免从头覆盖。

最小实现

下面的函数只依赖标准库,打开源和目标文件描述符,循环调用 os.sendfile。chunk 控制单次发送上限,避免一次传入过大的计数。os.sendfile 的 offset 参数指向源文件偏移,不改变源文件描述符的当前偏移;目标文件描述符的写入位置会随发送前进。

import errno
import os
import shutil

_FALLBACK_ERRNOS = {
    errno.ENOSYS,
    errno.EINVAL,
    errno.EXDEV,
    errno.EOPNOTSUPP,
    getattr(errno, 'ENOTSUP', errno.EOPNOTSUPP),
}

class _LimitedReader:
    def __init__(self, f, limit):
        self._f = f
        self._left = limit

    def read(self, size=-1):
        if self._left <= 0:
            return b''
        if size is None or size < 0:
            size = self._left
        else:
            size = min(size, self._left)
        data = self._f.read(size)
        self._left -= len(data)
        return data

def _fallback_copy(src_path, dst_path, src_offset, total, copied, chunk):
    if copied >= total:
        return copied
    with open(src_path, 'rb') as src, open(dst_path, 'r+b') as dst:
        src.seek(src_offset + copied)
        dst.seek(copied)
        reader = _LimitedReader(src, total - copied)
        shutil.copyfileobj(reader, dst, length=chunk)
        if reader._left != 0:
            raise OSError(errno.EIO, 'source truncated during fallback copy')
    return total

def copy_range_sendfile(src_path, dst_path, offset=0, length=None, chunk=1024 * 1024):
    if chunk <= 0:
        raise ValueError('chunk must be positive')

    src_fd = os.open(src_path, os.O_RDONLY)
    dst_fd = None
    try:
        st = os.fstat(src_fd)
        if offset < 0 or offset > st.st_size:
            raise ValueError('offset out of range')

        available = st.st_size - offset
        if length is None or length > available:
            length = available
        if length < 0:
            raise ValueError('length must be non-negative')

        dst_fd = os.open(
            dst_path,
            os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
            0o644,
        )
        copied = 0
        try:
            if not hasattr(os, 'sendfile'):
                raise OSError(errno.ENOSYS, 'os.sendfile is unavailable')

            while copied < length:
                n = min(chunk, length - copied)
                try:
                    sent = os.sendfile(dst_fd, src_fd, offset + copied, n)
                except InterruptedError:
                    continue
                except OSError as e:
                    if e.errno == errno.EINTR:
                        continue
                    raise

                if sent == 0:
                    raise OSError(errno.EIO, 'unexpected EOF from sendfile')
                copied += sent

            return copied
        except OSError as e:
            if e.errno not in _FALLBACK_ERRNOS:
                raise
            os.close(dst_fd)
            dst_fd = None
            return _fallback_copy(
                src_path, dst_path, offset, length, copied, chunk
            )
    finally:
        if dst_fd is not None:
            os.close(dst_fd)
        os.close(src_fd)

if __name__ == '__main__':
    import sys
    source, target = sys.argv[1], sys.argv[2]
    n = copy_range_sendfile(source, target)
    print(n)

函数返回实际复制的字节数。若指定的 length 超过源文件从 offset 到 EOF 的可用长度,会按可用长度截断;若 offset 越界则抛出 ValueError。目标文件以截断方式创建,因此不要指向仍需保留旧内容的文件。

运行与 strace 验证

把代码保存为 copy_sendfile.py,准备两个占位路径后运行:

python3 copy_sendfile.py /data/source.bin /data/target.bin

想观察是否走到 sendfile 系统调用,可以用 strace 只跟踪相关调用:

strace -e trace=sendfile,openat,read,write \
  python3 copy_sendfile.py /data/source.bin /data/target.bin

正常情况下应能看到 sendfile( 调用记录;如果只看到大量 read/write,可能是代码进入了回退路径,或当前环境不支持该调用。strace 的输出受内核、权限和 Python 启动过程影响,不应把某一次输出当作固定结论。

边界与限制

  • sendfile 对输入输出文件描述符有要求。普通文件到普通文件在常见 Linux 文件系统上可用,但跨文件系统、特殊文件系统或加密/压缩层可能返回不支持错误,此时依赖回退路径。
  • 代码假设文件描述符是阻塞模式。若传入非阻塞描述符,可能遇到 EAGAIN,本示例没有做等待重试。
  • EINTR 已显式重试;但其他错误如权限不足、磁盘空间不足不会回退,而会继续抛出,避免掩盖真实问题。
  • 回退使用 shutil.copyfileobj 从已复制位置续传,依赖目标文件已存在且可读写。若复制期间源文件被截断,回退会检测剩余长度不足并抛出 EIO。
  • sendfile 只复制文件内容,不负责保留权限、时间戳、扩展属性、ACL 或稀疏文件空洞等元数据。需要时应在复制完成后单独处理,并按需调用 fsync。
  • 不要把同一个目标文件描述符同时交给带缓冲的 Python 文件对象和 os.sendfile 混用,否则写入位置可能不符合预期。多线程共享描述符时也应自行加锁。
  • chunk 不是越大越好。设置合理上限可以控制单次系统调用参数,并让循环在部分发送时保持清晰。
【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。