Bài 26 giới thiệu defer. Bài này về một chỗ dùng defer sai mà rất nhiều mã Go mắc phải.
defer f.Close() không phải lúc nào cũng đủ
Với tệp mở để đọc, bỏ qua lỗi đóng là chấp nhận được:
f, err := os.Open(ten)
if err != nil { return err }
defer f.Close() // ổn
Với tệp mở để ghi thì không:
f, err := os.Create(ten)
if err != nil { return err }
defer f.Close() // NGUY HIỂM
_, err = f.Write(du)
return err // trả nil nếu Write thành công
Vấn đề: dữ liệu ghi có thể còn nằm trong bộ đệm của hệ điều hành. Lỗi thật — đĩa đầy, hết hạn ngạch, lỗi mạng với tệp trên NFS — chỉ lộ ra khi Close() xả bộ đệm.
Với mã trên, Close() chạy trong defer, lỗi của nó bị vứt đi, và hàm trả về nil. Chương trình báo ghi thành công trong khi tệp hỏng hoặc thiếu dữ liệu.
Mẫu đúng cho tệp ghi
func Ghi(ten string, du []byte) (err error) {
f, err := os.Create(ten)
if err != nil { return err }
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = cerr
}
}()
_, err = f.Write(du)
return err
}
Ba chi tiết:
Named return err để defer sửa được giá trị trả về — đúng cơ chế ở bài 26.
cerr != nil && err == nil — chỉ ghi đè khi chưa có lỗi. Lỗi Write quan trọng hơn lỗi Close, và bạn không muốn che nó.
Vẫn dùng defer để Close chạy kể cả khi có panic.
Nếu thấy mẫu này dài dòng, hãy nhớ nó chỉ cần cho tệp ghi và những thứ có bộ đệm. Với đọc thì defer f.Close() là đủ.
Đóng hai lần
đóng OK, dữ liệu đã xả xuống đĩa
đóng LẦN HAI -> close /tmp/t...: file already closed
os.File.Close() lần hai trả lỗi chứ không panic. Nên mẫu "đóng tường minh rồi vẫn defer cho chắc" sẽ sinh ra một lỗi giả:
defer f.Close()
...
if err := f.Close(); err != nil { return err } // defer sẽ đóng lần hai
Không nguy hiểm, nhưng nếu defer của bạn bắt lỗi như mẫu ở trên thì nó sẽ báo file already closed một cách vô nghĩa. Dùng một trong hai cách, đừng cả hai.
Lưu ý: không phải mọi kiểu đều chịu được đóng hai lần. Đóng một channel hai lần là panic — chi tiết ở bài 32.
Thân HTTP response
Đây là chỗ rò rỉ phổ biến nhất trong mã Go:
resp, err := http.Get(url)
if err != nil { return err }
defer resp.Body.Close() // BẮT BUỘC
Không đóng Body thì kết nối không được trả về pool và bạn rò rỉ file descriptor. Với dịch vụ gọi API liên tục, nó dẫn tới too many open files sau vài giờ.
Và có một chi tiết ít người biết: để tái dùng được kết nối, bạn phải đọc hết body trước khi đóng.
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
Bỏ qua phần thân mà đóng ngay thì Go phải đóng cả kết nối TCP, và lợi ích của keep-alive biến mất. Bài 47 sẽ đo chuyện này.
Thứ tự cũng quan trọng: kiểm err trước, vì khi err != nil thì resp là nil và resp.Body.Close() sẽ panic.
sync.Once cho đóng an toàn
Khi nhiều goroutine có thể cùng đóng:
type Dich struct {
dong sync.Once
f *os.File
}
func (d *Dich) Close() error {
var err error
d.dong.Do(func() { err = d.f.Close() })
return err
}
sync.Once bảo đảm chạy đúng một lần dù gọi từ bao nhiêu goroutine. Đây cũng là ví dụ zero value dùng được ngay ở bài 18 — không cần khởi tạo.
Chú ý một hạn chế: lần gọi thứ hai trả về nil chứ không trả lại lỗi của lần đầu. Cần giữ lỗi thì cất nó vào trường của struct.
Danh sách kiểm
Với mỗi tài nguyên bạn mở, hỏi bốn câu:
Có defer Close ngay sau khi kiểm lỗi mở không?
Đây là tài nguyên có bộ đệm không? Có thì phải bắt lỗi Close.
Có nằm trong vòng lặp không? Có thì tách thành hàm riêng — bài 26.
Có thể bị đóng từ nhiều goroutine không? Có thì cần sync.Once.
Thử ba mươi giây
grep -rn 'os.Create\|os.OpenFile' --include='*.go' -A3 | grep -c 'defer.*Close()'
So con số đó với số lần bạn ghi tệp. Mỗi chỗ ghi mà chỉ có defer f.Close() trần là một chỗ lỗi đĩa có thể biến mất — và bạn sẽ chỉ biết khi người dùng báo tệp hỏng.
Ngày mai bắt đầu chặng đồng thời: goroutine và chi phí thật của nó.