Hình dung việc ghi tệp như gửi một lá thư. Write là lúc bạn bỏ thư vào thùng — cảm giác xong rồi, nhẹ cả người. Nhưng lá thư chỉ thật sự đi khi bưu điện tới gom và gửi đi, và đó là lúc Close xả bộ đệm xuống đĩa. Nếu thùng đầy, nếu bưu điện từ chối — đĩa hết chỗ, hết hạn ngạch, lỗi mạng với tệp trên NFS — bạn chỉ biết ở bước gửi, không phải bước bỏ thư. Và defer f.Close() nuốt mất lỗi của bước gửi chính là quay lưng bỏ đi, đinh ninh thư đã tới nơi, trong khi nó kẹt lại. 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 — đọc thì không có lá thư nào để gửi:
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.
Muốn soi nhanh dự án mình, đếm rồi so hai con số:
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.
Mẫu số chung
Có một sự thật ít người để ý mà đúng ở mọi ngôn ngữ: đóng một tài nguyên đang ghi bản thân nó là một thao tác có thể thất bại. Dữ liệu nằm trong bộ đệm và chỉ được xả xuống lúc đóng, nên lỗi đĩa-đầy, hết-hạn-ngạch, lỗi-mạng lộ ra ở đó, không phải lúc Write. Trang man của POSIX nói thẳng: không kiểm giá trị trả về của close() là một lỗi lập trình phổ biến nhưng nghiêm trọng.
- C:
fwritecó thể báo thành công trong khifclosethất bại lúc xả — nên phải kiểmfclose, điều rất nhiều người quên. - Java:
BufferedWriter.close()xả và có thể ném ngoại lệ;try-with-resourcescó phơi nó ra, nhưng mộtclose()nuốt trongfinallylàm mất nó y nhưdefernuốt lỗi của Go. - Python:
close()trong khốiwithcũng có thể lỗi lúc xả, và cũng thường bị bỏ qua.
Với đọc thì không có gì để xả, nên bỏ qua lỗi đóng là chấp nhận được — chính sự bất đối xứng đọc/ghi đó là cả bài học. Và hai cái bẫy dọn-tài-nguyên khác lặp lại khắp nơi: thân HTTP response phải được đóng (và thường phải đọc cạn) nếu không rò rỉ kết nối trong pool tới lúc too many open files — đúng như HttpClient của Java, requests của Python, client của Node; và đóng hai lần trải từ vô hại tới chí mạng tuỳ kiểu — tệp thì idempotent, còn channel của Go thì panic — nên phải biết hợp đồng của tài nguyên mình cầm. Sợi chỉ chung đáng mang theo: "tôi đã ghi" không phải "nó đã được lưu" — việc lưu có thể hỏng đúng lúc xả bộ đệm, và cách duy nhất để biết là kiểm cái Close.
Ngày mai bắt đầu chặng đồng thời: goroutine và chi phí thật của nó.
Bài tập làm thử
Bài 1 (đọc hiểu). Đoạn mã sau ghi dữ liệu ra tệp. Hàm có báo lỗi đúng không nếu đĩa hết dung lượng ngay lúc hệ điều hành xả bộ đệm?
func Ghi(ten string, du []byte) error {
f, err := os.Create(ten)
if err != nil {
return err
}
defer f.Close()
_, err = f.Write(du)
return err
}
Đáp án
Không. f.Write thường chỉ ghi vào bộ đệm của hệ điều hành nên trả về nil. Lỗi đĩa đầy chỉ lộ ra lúc Close() xả bộ đệm xuống đĩa, nhưng Close() ở đây chạy trong defer nên lỗi của nó bị vứt đi. Hàm trả về nil dù dữ liệu chưa thực sự được lưu.
Bài 2 (sửa lỗi). Sửa lại hàm Ghi ở trên để không nuốt mất lỗi của Close(), đồng thời vẫn ưu tiên lỗi của Write nếu cả hai đều hỏng.
Đáp án
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
}
Cần named return err để defer sửa được giá trị trả về, và chỉ ghi đè err khi nó đang nil — lỗi Write quan trọng hơn lỗi Close.
Bài 3 (vận dụng thực tế). Vì sao đoạn mã sau bị coi là rò rỉ tài nguyên nghiêm trọng khi chạy trong một dịch vụ gọi API liên tục, và tại sao thứ tự kiểm err trước khi defer resp.Body.Close() lại quan trọng?
resp, err := http.Get(url)
defer resp.Body.Close()
if err != nil {
return err
}
Đáp án
Hai vấn đề. Một, đoạn mã gọi defer resp.Body.Close() trước khi kiểm err — khi err != nil thì resp là nil, nên resp.Body.Close() sẽ panic. Phải kiểm err trước, defer sau. Hai, ngay cả khi sửa đúng thứ tự, không đóng Body thì kết nối không được trả về pool, gây rò rỉ file descriptor, và với dịch vụ gọi API liên tục sẽ dẫn tới lỗi too many open files sau vài giờ.
Bài 4 (bẫy/đánh đổi). Vì sao mẫu bắt lỗi Close bằng named return (bài 2) không dùng được cho tệp mở để đọc như trong đoạn dưới đây? Có cần sửa gì không?
f, err := os.Open(ten)
if err != nil { return err }
defer f.Close()
Đáp án
Không cần sửa. Với tệp mở để đọc, không có dữ liệu nào đang chờ được "xả" xuống đĩa khi đóng — không có lá thư nào để gửi — nên bỏ qua lỗi đóng là chấp nhận được. Mẫu bắt lỗi Close phức tạp chỉ cần thiết cho tài nguyên có bộ đệm và đang ghi; áp nó vào mọi defer f.Close() là làm mã dài dòng không cần thiết.
Bài 5 (đọc hiểu — sync.Once). Với đoạn mã dưới đây, nếu hai goroutine cùng gọi d.Close() gần như đồng thời, và lần gọi đầu tiên trả về lỗi disk full, thì lần gọi thứ hai (dù chạy sau khi lần đầu đã xong) trả về gì?
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
}
Đáp án
nil. sync.Once chỉ chạy hàm bên trong Do đúng một lần; lần gọi thứ hai không chạy lại hàm đó nên biến err cục bộ của lần gọi thứ hai không bao giờ được gán — nó giữ giá trị zero là nil. Đây là hạn chế đã nêu trong bài: lỗi của lần đầu bị mất với các lần gọi sau, muốn giữ lại thì phải cất lỗi vào một trường của struct.