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:

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ó.