第一章:Go文件操作的底层原理与常见陷阱
Go 的文件操作看似简单,实则深度依赖操作系统提供的系统调用(如 open, read, write, close),其标准库 os 和 io 包是对这些底层接口的封装。os.File 本质是一个持有文件描述符(fd int)的结构体,在 Unix-like 系统中,该描述符是内核维护的索引;在 Windows 上则对应 Handle。理解这一抽象层级,是规避资源泄漏与竞态问题的前提。
文件描述符泄漏的典型场景
未显式调用 Close() 或忽略 Close() 的返回错误,会导致 fd 持续累积,最终触发 too many open files 错误。以下代码存在隐患:
func unsafeRead(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
// ❌ 忘记 defer f.Close() —— 若后续 Read 失败,f 将永不关闭
return io.ReadAll(f)
}
正确做法应确保 Close() 总被执行,并检查其错误(例如磁盘满时 Close() 可能写入失败):
func safeRead(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer func() {
if closeErr := f.Close(); closeErr != nil {
// 记录 closeErr,但不覆盖主错误(若 Read 已出错)
log.Printf("failed to close file %s: %v", path, closeErr)
}
}()
return io.ReadAll(f)
}
缓冲与同步行为差异
os.WriteFile 是原子性写入(内部使用临时文件 + rename),而 *os.File.Write 直接写入内核缓冲区,需手动调用 f.Sync() 持久化到磁盘。以下对比说明行为差异:
| 操作方式 | 是否保证落盘 | 是否原子 | 适用场景 |
|---|---|---|---|
os.WriteFile |
否(除非指定 O_SYNC) | 是 | 配置文件、小数据写入 |
f.Write + f.Sync |
是 | 否 | 日志、关键事务数据 |
并发访问同一文件的风险
多个 goroutine 对同一 *os.File 实例并发 Write 会导致数据交错(Write 不是线程安全的)。应使用 sync.Mutex 保护,或改用带锁的 bufio.Writer(注意:bufio.Writer 本身不提供并发安全,仍需外部同步)。
第二章:路径处理不当引发的错误
2.1 绝对路径与相对路径的混淆及跨平台兼容性修复
路径处理是跨平台应用中最易被忽视的“隐形陷阱”。Windows 使用反斜杠 \ 和盘符(如 C:\app\config.json),而 Unix-like 系统依赖正斜杠 / 和无根前缀(如 /home/user/app/config.json)。混用硬编码路径将导致 FileNotFoundError 或静默读取错误。
路径构造的典型误写
# ❌ 危险:拼接字符串 + 平台敏感分隔符
config_path = "data" + os.sep + "settings.cfg" # 仍依赖 os.sep,未解耦语义
# ✅ 推荐:pathlib 提供声明式、跨平台抽象
from pathlib import Path
config_path = Path("data") / "settings.cfg" # 自动适配 / 或 \
Path("data") / "settings.cfg" 利用重载 / 运算符,内部调用 joinpath(),屏蔽了 os.sep 和驱动器逻辑,确保在 Windows/macOS/Linux 下生成语义一致的路径对象。
常见路径场景对比
| 场景 | 绝对路径示例 | 相对路径示例 | 风险点 |
|---|---|---|---|
| 配置文件加载 | C:\app\conf\app.ini |
./conf/app.ini |
相对路径随 cwd 变化失效 |
| 资源包内资源定位 | /usr/lib/myapp/assets/ |
assets/logo.png |
__file__ 基准缺失导致错位 |
graph TD
A[获取当前模块目录] --> B[Path(__file__).parent]
B --> C[构建资源路径]
C --> D[resolve() 消除 .. 和 .]
D --> E[跨平台安全路径对象]
2.2 filepath.Join 未正确处理空字符串导致路径截断的实战分析
问题复现场景
当 filepath.Join 接收含空字符串的参数时,会跳过该段并隐式截断其后的路径层级:
package main
import (
"fmt"
"path/filepath"
)
func main() {
// ❌ 危险调用:空字符串导致后续 "config" 被丢弃
path := filepath.Join("/app", "", "config", "app.yaml")
fmt.Println(path) // 输出:"/app/config/app.yaml" —— 表面正常?再看下例
}
逻辑分析:filepath.Join 对每个参数执行 strings.TrimSpace 后判空;若为空,则完全跳过该参数及后续所有参数(Go 1.22+ 已修正,但旧版本仍广泛存在)。此处 "" 后的 "config" 实际被保留,但若写成 filepath.Join("/app", "", "../etc"),则 ../etc 将被忽略。
典型误用链路
- 用户拼接动态路径片段(如租户ID、环境名)
- 某片段因配置缺失为空字符串
Join静默丢弃后续关键子路径
| 输入参数 | Go 1.20 实际输出 | 风险等级 |
|---|---|---|
["/data", "", "logs"] |
/data/logs |
⚠️ 中 |
["/home", "", "../tmp"] |
/home(../tmp丢失) |
🔴 高 |
安全替代方案
使用 filepath.Clean(filepath.Join(...)) 并显式校验空参数,或改用 path.Join(不处理系统路径分隔符)配合 strings.Join。
2.3 Windows/Linux 路径分隔符硬编码引发的 panic 案例与标准化模板
典型 panic 场景
以下代码在 Windows 下正常,Linux 下触发 panic: invalid path:
func loadConfig() string {
return "etc" + "/" + "app.yaml" // ❌ 硬编码 '/'
}
逻辑分析:
/在 Windows 上虽被部分 API 兼容,但os.Stat()、filepath.Join()等底层调用依赖os.PathSeparator。硬编码/导致filepath.FromSlash()未被调用,跨平台路径解析失败。
标准化方案对比
| 方式 | 安全性 | 可读性 | 推荐度 |
|---|---|---|---|
字符串拼接 + "/" |
❌ | 高 | ⚠️ 仅限单平台测试 |
filepath.Join("etc", "app.yaml") |
✅ | 中 | ✅ 强烈推荐 |
path.Join()(非 filepath) |
❌ | 低 | ❌ 会忽略 OS 语义 |
正确实践模板
import "path/filepath"
func getConfigPath() string {
return filepath.Join("etc", "app.yaml") // ✅ 自动适配 \ 或 /
}
filepath.Join内部根据filepath.Separator(即os.PathSeparator)拼接,确保跨平台一致性。
2.4 os.Getwd() 在多 goroutine 环境下被意外修改的风险与隔离方案
os.Getwd() 本身是线程安全的,但其返回值依赖底层 getcwd(3) 系统调用——而该调用在某些旧版 libc(如 glibc chdir(),可能返回错误路径。
风险根源
- 多 goroutine 中混用
os.Chdir()会全局修改进程当前工作目录(CWD) os.Getwd()读取的是进程级状态,非 goroutine 局部状态
隔离方案对比
| 方案 | 线程安全 | 零拷贝 | 适用场景 |
|---|---|---|---|
filepath.Abs(".") |
✅ | ❌(需系统调用+字符串拼接) | 快速替代,无副作用 |
os.Getwd() + sync.RWMutex |
✅ | ✅ | 高频读、低频写 CWD |
goroutine-local 路径缓存 |
✅ | ✅ | 已知路径生命周期明确 |
var (
wdMu sync.RWMutex
cachedWd string
)
func SafeGetwd() (string, error) {
wdMu.RLock()
if cachedWd != "" {
defer wdMu.RUnlock()
return cachedWd, nil // 缓存命中,零开销
}
wdMu.RUnlock()
wdMu.Lock()
defer wdMu.Unlock()
if cachedWd == "" { // 双检锁防重复初始化
var err error
cachedWd, err = os.Getwd()
return cachedWd, err
}
return cachedWd, nil
}
逻辑分析:使用双检锁(Double-Checked Locking)避免重复调用
os.Getwd();首次调用后结果持久化至包级变量cachedWd,后续读取仅需RWMutex读锁,开销趋近于原子读。参数cachedWd为包级可变状态,需确保初始化期间无竞态——此处由互斥锁保障。
graph TD
A[goroutine 调用 SafeGetwd] --> B{cachedWd 是否为空?}
B -->|否| C[直接返回缓存值]
B -->|是| D[获取写锁]
D --> E[再次检查 cachedWd]
E -->|仍为空| F[调用 os.Getwd 并缓存]
E -->|已填充| C
2.5 符号链接解析失控(os.Readlink + os.Stat 循环)的检测与安全遍历策略
符号链接(symlink)深度嵌套或构成环路时,os.Readlink 与 os.Stat 的朴素组合极易触发无限循环或栈溢出。
常见失控模式
- 跨目录相对路径循环:
a → b → ../a - 绝对路径自引用:
/tmp/self → /tmp/self - 多层间接跳转:
link1 → link2 → link3 → link1
安全遍历核心约束
- 限制最大解析深度(建议 ≤ 40,Linux 内核默认为 40)
- 维护已访问 inode+device 元组集合,避免重复解析
func safeReadlink(path string, maxDepth int) (string, error) {
if maxDepth <= 0 {
return "", fmt.Errorf("symlink loop detected at depth limit")
}
target, err := os.Readlink(path)
if err != nil {
return "", err
}
absTarget, err := filepath.Abs(filepath.Join(filepath.Dir(path), target))
if err != nil {
return "", err
}
stat, err := os.Stat(absTarget)
if err != nil {
return "", err
}
// 检查是否为符号链接,继续递归前校验深度
if stat.Mode()&os.ModeSymlink != 0 {
return safeReadlink(absTarget, maxDepth-1)
}
return absTarget, nil
}
逻辑说明:该函数通过显式深度计数(
maxDepth)阻断无限递归;filepath.Abs消除相对路径歧义;每次递归前检查目标是否仍为 symlink,避免误入普通文件。参数path为待解析链接路径,maxDepth是防御性阈值,非硬性系统限制。
防御能力对比表
| 策略 | 检测环路 | 限深防护 | inode 去重 | 实现复杂度 |
|---|---|---|---|---|
纯 os.Readlink 循环调用 |
❌ | ❌ | ❌ | 低 |
深度计数 + Abs 校验 |
⚠️(依赖路径规范) | ✅ | ❌ | 中 |
inode+dev + 深度双校验 |
✅ | ✅ | ✅ | 高 |
graph TD
A[Start: safeReadlink] --> B{maxDepth ≤ 0?}
B -->|Yes| C[Return loop error]
B -->|No| D[Readlink path]
D --> E[Absolve target]
E --> F[Stat target]
F --> G{Is Symlink?}
G -->|Yes| A
G -->|No| H[Return final path]
第三章:权限与所有权误判导致的失败
3.1 os.OpenFile 权限掩码(0644 vs 0600)在不同操作系统上的语义差异
Unix-like 系统(Linux/macOS)严格遵循 POSIX 权限模型,0644 表示 rw-r--r--(所有者可读写,组和其他用户仅读),而 0600 为 rw-------(仅所有者可读写)。Windows 则忽略该掩码——其 ACL 模型不映射 os.FileMode 的权限位,os.OpenFile 中传入的 perm 参数仅影响文件创建时的“模拟权限”,实际由 NTFS ACL 决定。
权限掩码的实际行为对比
| 系统 | 0644 效果 |
0600 效果 |
是否影响后续 chmod |
|---|---|---|---|
| Linux | 正确设置 r/w 权限 | 仅所有者可读写 | ✅ 是 |
| macOS | 同 Linux,但受 SIP 可能限制 | 同 Linux | ✅ 是 |
| Windows | 被忽略(创建后默认继承父目录 ACL) | 被忽略 | ❌ 否(需调用 syscall.SetFileAttributes) |
f, err := os.OpenFile("config.json", os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
log.Fatal(err)
}
// 0600 在 Linux/macOS 上确保密钥文件不被其他用户访问;
// 在 Windows 上需额外调用 os.Chmod(f.Name(), 0600) 并捕获 syscall.EACCES
逻辑分析:
os.OpenFile的perm参数仅在os.O_CREATE标志存在时生效;它本质是syscall.Openat的mode参数,在 Linux 中直接传递给openat(2)系统调用,而 Windows Go 运行时将其转为空操作(nop)。
3.2 umask 干预下 chmod 失效的深层机制与可移植性修复代码
为什么 chmod 有时“不生效”?
chmod 设置的权限会与进程的 umask 值按位取与(实际为 ~umask & requested_mode),导致最终权限 ≤ 请求权限。例如 umask 027 时,chmod 777 file 实际仅得 750。
可移植性修复核心逻辑
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
mode_t force_mode(const char *path, mode_t target) {
mode_t old_mask = umask(0); // 临时清空掩码
int ret = chmod(path, target); // 直接施加目标权限
umask(old_mask); // 恢复原始 umask(关键!)
return ret == 0 ? target : (mode_t)-1;
}
逻辑分析:先保存当前
umask,设为确保chmod不被截断,执行后立即还原——避免影响同进程其他文件操作。参数target应为绝对权限值(如0644),非相对模式。
跨平台行为差异简表
| 系统 | umask 继承规则 |
chmod 对目录执行位影响 |
|---|---|---|
| Linux | 进程级继承,子进程复制 | 严格遵循 umask 截断 |
| macOS | 同 Linux | 同左 |
| FreeBSD | umask 不影响 chmod 结果(例外) |
需显式检测并绕过 |
权限计算流程
graph TD
A[调用 chmod path 0777] --> B[内核读取当前 umask]
B --> C[计算 effective = 0777 & ~umask]
C --> D[写入 inode.i_mode = effective]
3.3 以非 root 用户操作 /tmp 或系统目录时的静默失败诊断流程
当普通用户尝试向 /tmp(或 /var/log, /usr/local/bin 等受保护路径)写入文件却未报错时,往往因 umask、sticky bit 或 CAP_DAC_OVERRIDE 缺失导致静默截断或权限绕过失败。
常见静默表现
cp file /tmp/返回 0,但文件未出现echo "x" > /tmp/test.log无提示却无文件生成mkdir /tmp/mydir成功,但ls -ld /tmp/mydir显示属主为root
快速诊断命令
# 检查目标目录实际权限与粘滞位
ls -ld /tmp
# 输出示例:drwxrwxrwt 14 root root 4096 Jun 5 10:22 /tmp
# 关键点:末位 't' 表示 sticky bit,普通用户可写但仅能删自己文件
该命令验证 /tmp 是否启用 sticky bit(t),若缺失则可能被篡改;rwxrwxrwt 中最后 t 表明其他用户可创建文件,但无法删除他人文件——这是静默失败的常见根源。
权限决策流程
graph TD
A[尝试写入 /tmp] --> B{进程有效UID == 0?}
B -->|否| C[检查目录写+执行权限]
C --> D{父目录含 sticky bit?}
D -->|是| E[仅允许删/重命名自身文件]
D -->|否| F[可能成功创建但被 SELinux/AppArmor 阻断]
典型错误码映射表
| errno | 符号名 | 触发场景 |
|---|---|---|
| 13 | EACCES | 目录无 x 权限(无法 traverse) |
| 17 | EEXIST | mkdir 时目标已存在且非空 |
| 30 | EROFS | /tmp 挂载为只读(如 tmpfs 配置错误) |
第四章:并发与生命周期管理缺陷
4.1 文件句柄泄漏:defer f.Close() 在 err != nil 分支缺失的典型模式与自动资源管理封装
常见反模式示例
func readConfig(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err // ❌ 忘记关闭 f,但 f 可能已成功打开!
}
defer f.Close() // ✅ 仅在 err == nil 时注册,err != nil 时 f 未被关闭
return io.ReadAll(f)
}
逻辑分析:os.Open 在部分错误(如 EACCES)前可能已分配内核文件描述符,但 Go 运行时未将其置为 nil;err != nil 早退导致 defer 语句永不执行,引发句柄泄漏。
安全封装方案
| 方案 | 是否确保关闭 | 是否支持 early-return | 推荐度 |
|---|---|---|---|
defer f.Close() |
否(条件依赖) | 否 | ⚠️ |
defer func(){_ = f.Close()}() |
是(无条件) | 是 | ✅ |
io.ReadCloser 封装 |
是 | 是 | ✅✅ |
资源安全流程
graph TD
A[Open file] --> B{err != nil?}
B -->|Yes| C[立即关闭 f]
B -->|No| D[注册 defer Close]
D --> E[业务逻辑]
C & E --> F[资源释放完成]
4.2 多 goroutine 同时写入同一 *os.File 引发数据竞争的 race detector 验证与 sync.Pool 缓冲优化
数据竞争复现
以下代码触发 go run -race 报告写竞争:
f, _ := os.Create("log.txt")
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
f.Write([]byte(fmt.Sprintf("goroutine %d\n", id))) // ❌ 竞争点:*os.File.Write 非并发安全
}(i)
}
wg.Wait()
*os.File 的底层 fd 和偏移量操作未加锁,多 goroutine 直接调用 Write() 会交错写入、覆盖或错位。
sync.Pool 缓冲优化
改用线程本地缓冲池避免共享写:
| 方案 | 并发安全 | 内存开销 | 吞吐提升 |
|---|---|---|---|
| 直接 Write | 否 | 低 | 低(受锁争用) |
sync.Mutex 包裹 |
是 | 低 | 中(串行化) |
sync.Pool + bufio.Writer |
是 | 中(对象复用) | 高(批量刷盘) |
优化后流程
graph TD
A[goroutine] --> B[从 sync.Pool 获取 bufio.Writer]
B --> C[写入本地缓冲区]
C --> D[缓冲满/显式 Flush]
D --> E[原子写入 *os.File]
E --> F[Put 回 Pool]
缓冲区复用显著降低系统调用频次,同时消除跨 goroutine 文件句柄竞争。
4.3 os.RemoveAll 在遍历中删除父目录导致“directory not empty”错误的原子化替代方案
当 os.RemoveAll 被调用于正在遍历的父目录时,底层文件系统可能因目录项未完全清理而返回 directory not empty 错误——这本质是竞态条件:filepath.WalkDir 的迭代器持有打开的目录句柄,而 RemoveAll 尝试递归清空时触发内核级 ENOTEMPTY。
核心矛盾:遍历与删除的语义冲突
WalkDir默认复用os.DirEntry,不保证目录句柄及时释放RemoveAll需独占目录所有权,但子项可能仍被遍历器引用
原子化替代:延迟删除 + 拓扑排序
// 先收集所有路径(自底向上),再逆序删除
paths := []string{}
filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil { return err }
paths = append(paths, path)
return nil
})
// 逆序确保子目录先于父目录被移除
for i := len(paths) - 1; i >= 0; i-- {
os.Remove(paths[i]) // 不用 RemoveAll,避免递归干扰
}
逻辑分析:
WalkDir仅读取元数据,不长期持有句柄;逆序遍历使叶子节点(文件/空目录)优先删除,父目录自然变空。os.Remove对空目录安全,对文件直接生效,规避了RemoveAll的递归重入问题。
| 方案 | 原子性 | 并发安全 | 适用场景 |
|---|---|---|---|
os.RemoveAll |
❌(非事务) | ❌ | 静态目录一次性清理 |
逆序 os.Remove |
✅(单路径原子) | ✅(无共享状态) | 动态遍历中清理 |
graph TD
A[开始遍历] --> B[收集全路径]
B --> C[按深度逆序排序]
C --> D[逐个 os.Remove]
D --> E[完成:父目录必为空]
4.4 ioutil.ReadFile 被滥用在大文件场景下的 OOM 风险与流式处理(io.Copy + bufio.Scanner)迁移模板
ioutil.ReadFile 会将整个文件一次性载入内存,对 GB 级日志或导出文件极易触发 Out-of-Memory(OOM)。
OOM 风险对比分析
| 场景 | 内存占用 | 适用性 |
|---|---|---|
ioutil.ReadFile("big.log") |
≈ 文件大小 × 1.2×(Go runtime 开销) | ❌ 仅限 |
bufio.Scanner 流式读取 |
恒定 ~64KB 缓冲区 | ✅ 任意大小 |
迁移模板:从“全量加载”到“按行流式处理”
// ❌ 危险:大文件直接读取(已废弃的 ioutil)
// data, err := ioutil.ReadFile("access.log")
// ✅ 安全:流式逐行处理
file, _ := os.Open("access.log")
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text() // 自动截断换行符
processLine(line) // 自定义业务逻辑
}
逻辑说明:
bufio.Scanner默认使用 64KB 缓冲区,Scan()每次仅加载一行(非整文件),避免内存爆炸;Text()返回当前行字符串(不含\n),底层复用缓冲区,零额外分配。
核心迁移原则
- 用
os.Open+bufio.Scanner替代ioutil.ReadFile - 对二进制流,优先组合
io.Copy(dst, src)实现零拷贝传输 - 大文件写入同理:禁用
ioutil.WriteFile,改用os.Create+io.Copy
第五章:Go 1.22+ 文件 API 演进与避坑前瞻
Go 1.22 引入了 io/fs 包的实质性增强,并对 os 和 path/filepath 的底层行为进行了静默修正,尤其在跨平台文件路径解析、符号链接处理及并发安全 I/O 场景中暴露了若干关键兼容性断裂点。以下基于真实 CI 环境故障复盘与生产级文件服务重构经验展开说明。
路径规范化逻辑变更导致 symlink 解析失效
在 Go 1.21 中,filepath.EvalSymlinks("/a/b/../c") 会先归一化再解析符号链接;而 Go 1.22 改为严格按字面路径逐段解析 symlink,导致如下代码在 macOS 上行为突变:
// Go 1.21: 返回 /tmp/real → 正确
// Go 1.22: 返回 /tmp/../tmp/real → 解析失败(ENOENT)
real, _ := filepath.EvalSymlinks("/tmp/../tmp/real")
该问题已在 filepath.Clean() 行为文档中明确标注为“不保证等价于 EvalSymlinks”,需改用 filepath.Abs() + os.Stat() 组合校验。
os.DirFS 不再自动处理路径边界条件
os.DirFS("/data") 在 Go 1.22 中对 Open("///file.txt") 返回 fs.ErrInvalid(而非之前静默转为 /file.txt)。某日志归档服务因 Nginx 日志路径拼接含冗余斜杠,在升级后批量报错:
| 场景 | Go 1.21 行为 | Go 1.22 行为 | 修复方案 |
|---|---|---|---|
fs.Open("///log/app.log") |
成功打开 /data/log/app.log |
fs.ErrInvalid |
使用 filepath.Clean(path) 预处理输入 |
fs.ReadDir(".") 含隐藏文件 |
返回 . .. 及 .gitignore |
仅返回 . ..(.gitignore 被过滤) |
显式调用 fs.Glob(fsys, "*") 或启用 fs.ReadDir 的 fs.ReadDirEntry.IsDir() 判断 |
并发写入同一文件句柄引发 panic
Go 1.22 对 *os.File 的 WriteAt 实现引入了内部读写锁优化,但若多个 goroutine 共享同一 *os.File 并混用 Write() 与 WriteAt(),将触发 fatal error: concurrent write to file。某分布式配置同步器曾因此在高负载下每小时崩溃 3–5 次:
flowchart LR
A[goroutine-1] -->|Write\\noffset=0| B[(shared *os.File)]
C[goroutine-2] -->|WriteAt\\noffset=1024| B
D[goroutine-3] -->|Write\\noffset=2048| B
B --> E[panic: concurrent write]
根本解法是为每个 goroutine 分配独立 *os.File,或使用 io.MultiWriter + bytes.Buffer 做内存缓冲层。
fs.WalkDir 性能退化与替代方案
filepath.WalkDir 在 Go 1.22 中默认启用 fs.DirEntry.Type() 缓存,但若目录树深度 > 1000 层,递归栈开销激增 47%。某备份系统扫描 /var/lib/docker/overlay2 时耗时从 12s 升至 38s。实测采用 filepath.Glob("**/*.log") + 批量 os.Stat 并行处理,性能提升 3.2 倍。
io.ReadAll 读取大文件触发 OOM
io.ReadAll 在 Go 1.22 中移除了对 io.Reader.Size() 的隐式调用优化,导致 http.Response.Body(无 Size() 方法)读取 2GB 文件时直接分配 2GB 内存。某 Kubernetes 集群镜像元数据下载服务因此 OOM。必须改用分块读取:
buf := make([]byte, 64*1024)
for {
n, err := resp.Body.Read(buf)
if n > 0 { hash.Write(buf[:n]) }
if err == io.EOF { break }
} 