Python os.sendfile 零拷贝复制大文件实战
在 Linux 上,用 Python 的 read/write 循环复制大文件时,数据往往要先进入用户态缓冲区,再写回内核。对于备份、日志归档、镜像层搬运等场景,如果源和目标都是本地文件,可以尝试 os.sendfile:它把复制工作交给内核完成,减少用户态缓冲区参与。需要强调,所谓零拷贝是相对用户态缓冲区而言,内核内部仍会进行必要的数据搬运,并不等于完全没有拷贝。
问题场景
需求是复制大文件的一段范围:给定源路径、目标路径、源文件偏移 offset 和目标长度 length。复制过程中要处理两类常见情况:
os.sendfile可能只发送一部分字节,必须循环直到剩余长度为零。- 系统调用可能被信号中断,需要识别
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不是越大越好。设置合理上限可以控制单次系统调用参数,并让循环在部分发送时保持清晰。
- 点赞
- 收藏
- 关注作者
评论(0)