lolly/internal/handler/sendfile_linux.go
xfy d874f97765 fix(handler): 修复 sendfile 高并发下的连接断开处理
- EPIPE/ECONNRESET 不再 fallback,直接返回错误避免响应混乱
- 正确处理 EAGAIN/EWOULDBLOCK socket 缓冲区满,等待重试
- EINTR 信号中断正确重试
- 改用 SetBodyStream 确保 HTTP 头先发送再 sendfile
- 添加重试限制(100次)防止无限循环

测试结果:0 错误/100000 并发请求

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 09:54:08 +08:00

179 lines
4.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//go:build linux
// Package handler 提供 HTTP 请求处理器,包括路由、静态文件服务和零拷贝传输。
//
// 该文件包含 Linux 平台完整的 sendfile 实现(零拷贝 + 公共函数)。
//
// 作者xfy
package handler
import (
"io"
"net"
"os"
"syscall"
"time"
"github.com/valyala/fasthttp"
)
const (
// MinSendfileSize 使用 sendfile 的最小文件大小8KB
// 小于该值的文件使用普通 io.Copy避免系统调用开销。
MinSendfileSize = 8 * 1024
// sendfile 最大重试次数
sendfileMaxRetries = 100
// sendfile 重试等待时间
sendfileRetryDelay = 1 * time.Millisecond
)
// SendFile 零拷贝文件传输。
//
// 大文件使用系统调用直接从文件传输到 socket避免用户空间拷贝
// 从而减少 CPU 和内存开销,提升传输性能。
//
// 参数:
// - ctx: fasthttp 请求上下文,用于获取底层连接
// - file: 要传输的文件对象
// - offset: 文件起始偏移量(字节)
// - length: 传输长度(字节),-1 表示传输到文件末尾
//
// 返回值:
// - error: 传输过程中的错误
func SendFile(ctx *fasthttp.RequestCtx, file *os.File, offset, length int64) error {
// 小文件使用普通 io.Copy
if length < MinSendfileSize {
return copyFile(ctx, file, offset, length)
}
// 尝试获取 socket 文件描述符
conn := getNetConn(ctx)
if conn == nil {
return copyFile(ctx, file, offset, length)
}
// Linux 平台使用 sendfile 系统调用
err := linuxSendfile(conn, file.Fd(), offset, length)
if err != nil {
// EPIPE/ECONNRESET 表示客户端已断开,不应 fallback
// 因为 HTTP 头可能已发送fallback 会造成响应混乱
if err == syscall.EPIPE || err == syscall.ECONNRESET {
return err // 直接返回错误,不 fallback
}
// 其他错误尝试 fallback 到 io.Copy
return copyFile(ctx, file, offset, length)
}
return nil
}
// getNetConn 从 fasthttp.RequestCtx 获取底层 net.Conn。
func getNetConn(ctx *fasthttp.RequestCtx) net.Conn {
return ctx.Conn()
}
// copyFile 普通文件拷贝fallback
func copyFile(ctx *fasthttp.RequestCtx, file *os.File, offset, length int64) error {
if offset > 0 {
if _, err := file.Seek(offset, io.SeekStart); err != nil {
return err
}
}
if length > 0 {
_, err := io.CopyN(ctx, file, length)
return err
}
_, err := io.Copy(ctx, file)
return err
}
// linuxSendfile Linux sendfile 系统调用。
//
// 使用 Linux 特有的 sendfile 系统调用实现零拷贝传输。
// 正确处理临时错误EAGAIN、EINTR和连接断开EPIPE、ECONNRESET
func linuxSendfile(conn net.Conn, fileFd uintptr, _, length int64) error {
socketFd, err := getSocketFd(conn)
if err != nil {
return err
}
// Linux sendfile: sendfile(out_fd, in_fd, offset, count)
var sent int64
remain := length
retries := 0
for remain > 0 {
n, err := syscall.Sendfile(int(socketFd), int(fileFd), nil, int(remain))
if err != nil {
// 处理临时错误socket 缓冲区满,等待后重试
if err == syscall.EAGAIN || err == syscall.EWOULDBLOCK {
retries++
if retries > sendfileMaxRetries {
// 超过最大重试次数,返回错误
return err
}
// socket 缓冲区满,短暂等待后重试
time.Sleep(sendfileRetryDelay)
continue
}
// 被信号中断,重试
if err == syscall.EINTR {
retries++
if retries > sendfileMaxRetries {
return err
}
continue
}
// 客户端断开连接,返回错误让 fasthttp 知道请求未完成
// 注意:不要返回 nil否则 fasthttp 会发送 200 + 空 body
if err == syscall.EPIPE || err == syscall.ECONNRESET {
return err // 返回错误,让 fasthttp 处理连接断开
}
// 其他错误直接返回
return err
}
if n == 0 {
break // EOF 或连接关闭
}
// 成功发送数据,重置重试计数
retries = 0
sent += int64(n)
remain -= int64(n)
}
return nil
}
// getSocketFd 获取 socket 文件描述符。
//
// 从网络连接中提取底层的文件描述符,用于 sendfile 系统调用。
func getSocketFd(conn net.Conn) (uintptr, error) {
switch c := conn.(type) {
case *net.TCPConn:
file, err := c.File()
if err != nil {
return 0, err
}
defer func() { _ = file.Close() }()
return file.Fd(), nil
case *net.UnixConn:
file, err := c.File()
if err != nil {
return 0, err
}
defer func() { _ = file.Close() }()
return file.Fd(), nil
default:
return 0, syscall.ENOTSUP
}
}