Ôn phỏng vấn Golang

Ôn phỏng vấn

Câu hỏi phỏng vấn Golang thường gặp (kèm gợi ý trả lời)

Câu hỏi phỏng vấn Golang hay gặp: goroutine, channel, slice, interface, xử lý lỗi, context, generics, race condition và tối ưu hiệu năng. Gồm 12 câu do Drama Công Ty biên soạn: đọc ý chính rồi tự trả lời bằng ví dụ từ dự án của bạn.

  1. 01Goroutine khác gì OS thread? Làm sao chờ nhiều goroutine chạy xong?Junior

    Gợi ý trả lời

    Goroutine là "luồng nhẹ" do runtime Go quản lý: stack khởi đầu chỉ vài KB và tự giãn, chi phí tạo rất thấp. Scheduler của Go phân phối nhiều goroutine lên một số ít OS thread (mô hình M:N), số thread chạy song song tối đa theo GOMAXPROCS.

    Chờ nhiều goroutine bằng sync.WaitGroup:

    var wg sync.WaitGroup
    for _, u := range urls {
        wg.Add(1)
        go func() {
            defer wg.Done()
            fetch(u)
        }()
    }
    wg.Wait()
    

    Go 1.25 có thêm wg.Go(func() { ... }) gói gọn Add và Done.

    Hay sai: gọi wg.Add(1) bên trong goroutine, khiến Wait có thể trả về trước khi goroutine kịp đăng ký. Một lỗi khác: hàm main kết thúc thì mọi goroutine bị dừng theo, không ai chờ chúng.

  2. 02Array và slice khác nhau thế nào? Vì sao append đôi khi làm thay đổi cả slice khác?Junior

    Gợi ý trả lời

    Array có độ dài cố định và là giá trị (gán là copy toàn bộ). Slice là một "khung nhìn" gồm con trỏ đến mảng bên dưới, len và cap.

    • Gán hoặc cắt slice (b := a[1:3]) không copy dữ liệu; hai slice dùng chung mảng bên dưới.
    • append khi còn cap sẽ ghi thẳng vào mảng chung, nên có thể ghi đè dữ liệu slice khác đang thấy. Khi hết cap, nó cấp phát mảng mới và từ đó hai slice tách rời.
    a := []int{1, 2, 3, 4}
    b := a[:2]
    b = append(b, 99) // a thành [1 2 99 4]
    
    • Muốn tách hẳn: slices.Clone(a) hoặc dùng full slice expression a[:2:2] để giới hạn cap.
    • Biết trước kích thước thì make([]T, 0, n) để tránh cấp phát lại nhiều lần.

    Người phỏng vấn muốn nghe: luôn dùng giá trị trả về của append (s = append(s, x)).

  3. 03Go xử lý lỗi như thế nào? Khi nào dùng panic? errors.Is và errors.As để làm gì?Junior

    Gợi ý trả lời

    Lỗi trong Go là giá trị kiểu error, được trả về như kết quả thông thường và phải kiểm tra tường minh. panic chỉ dành cho tình huống không thể tiếp tục hoặc lỗi lập trình (ví dụ cấu hình bắt buộc bị thiếu lúc khởi động), không dùng cho lỗi nghiệp vụ.

    Bọc lỗi để giữ ngữ cảnh mà không mất lỗi gốc:

    if err != nil {
        return fmt.Errorf("load user %d: %w", id, err)
    }
    // Ở tầng trên:
    if errors.Is(err, sql.ErrNoRows) { /* trả 404 */ }
    
    • errors.Is: so với một lỗi cụ thể trong chuỗi bọc.
    • errors.As: lấy ra lỗi thuộc một kiểu cụ thể để đọc field của nó.
    • recover trong defer dùng ở biên (middleware HTTP) để một request panic không làm sập cả server.

    Hay sai: dùng %v thay %w khi bọc, làm errors.Is không tìm thấy lỗi gốc.

  4. 04Interface trong Go khác gì Java hay C#? Vì sao một interface chứa con trỏ nil lại khác nil?Junior

    Gợi ý trả lời

    Go implement interface ngầm định: kiểu nào có đủ method thì tự thoả mãn interface, không cần khai báo implements. Nhờ vậy interface thường được định nghĩa ở phía sử dụng, nhỏ gọn (io.Reader chỉ có một method), và dễ thay bằng mock khi test.

    Giá trị interface gồm hai phần: kiểu và giá trị. Nó chỉ bằng nil khi cả hai đều nil.

    func find() error {
        var err *MyError = nil
        return err // interface có kiểu *MyError, nên != nil
    }
    

    Caller kiểm tra err != nil sẽ thấy true dù "không có lỗi".

    Hay sai: khai báo interface lớn ở phía cài đặt cho mọi struct, theo thói quen từ Java. Câu hay được nhắc trong cộng đồng Go: nhận vào interface, trả về struct cụ thể. Khi không có lỗi, hãy return nil trực tiếp.

  5. 05Channel buffered và unbuffered khác nhau thế nào? select dùng để làm gì? Khi nào xảy ra deadlock?

    Gợi ý trả lời

    • Unbuffered: gửi bị chặn đến khi có bên nhận (và ngược lại). Đây là điểm đồng bộ giữa hai goroutine.
    • Buffered (make(chan T, n)): gửi chỉ bị chặn khi buffer đầy, nhận bị chặn khi buffer rỗng.

    select chờ trên nhiều channel cùng lúc, thường dùng cho timeout hoặc huỷ:

    select {
    case res := <-results:
        handle(res)
    case <-ctx.Done():
        return ctx.Err()
    }
    

    Quy tắc: chỉ bên gửi đóng channel; gửi vào channel đã đóng gây panic; nhận từ channel đã đóng trả giá trị zero ngay lập tức (v, ok := <-ch để phân biệt). for range ch dừng khi channel đóng.

    Deadlock xảy ra khi mọi goroutine đều đang chờ nhau, ví dụ gửi vào channel unbuffered mà không có ai nhận. Runtime báo "all goroutines are asleep" nếu toàn bộ chương trình bị kẹt; kẹt một phần thì không báo gì và thành goroutine leak.

  6. 06Map trong Go có an toàn khi dùng đồng thời không? Bạn phát hiện và xử lý race condition thế nào?

    Gợi ý trả lời

    Không. Đọc và ghi map đồng thời có thể làm runtime dừng chương trình với lỗi "concurrent map read and map write". Các biến dùng chung khác cũng có data race nhưng thường im lặng cho kết quả sai.

    Phát hiện bằng race detector: go test -race ./... hoặc go run -race. Nên bật trong CI.

    Cách xử lý:

    • sync.Mutex bọc map, hoặc sync.RWMutex khi đọc nhiều hơn ghi rất nhiều.
    • sync.Map hợp với vài trường hợp đặc thù (key ghi một lần đọc nhiều lần, hoặc mỗi goroutine dùng tập key riêng); không phải thay thế mặc định.
    • Bộ đếm đơn giản: atomic.Int64.
    • Hoặc tránh chia sẻ: để một goroutine sở hữu dữ liệu, các goroutine khác gửi yêu cầu qua channel.

    Hay sai: copy struct có chứa sync.Mutex (truyền theo giá trị), khiến mỗi bản có một khoá riêng. go vet cảnh báo được lỗi này.

  7. 07context.Context dùng để làm gì? Có những quy ước nào khi dùng context?

    Gợi ý trả lời

    Context mang tín hiệu huỷ, deadline và một ít giá trị gắn với request, truyền xuyên suốt chuỗi gọi hàm. Khi client ngắt kết nối hoặc hết thời gian, mọi thao tác bên dưới (truy vấn DB, gọi HTTP) đều có thể dừng sớm.

    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()
    row := db.QueryRowContext(ctx, query, id)
    

    Quy ước:

    • Context là tham số đầu tiên, tên ctx; không lưu context trong struct.
    • Luôn defer cancel() để giải phóng tài nguyên.
    • Goroutine chạy lâu phải kiểm tra ctx.Done().
    • WithValue chỉ dùng cho dữ liệu theo request (request ID, thông tin trace), không dùng để truyền tham số tuỳ chọn.

    Hay sai: dùng context của request cho việc chạy nền sau khi đã trả response. Request xong là context bị huỷ; hãy dùng context.WithoutCancel hoặc context riêng.

  8. 08defer hoạt động thế nào? Vòng lặp for kết hợp goroutine và closure có thay đổi gì từ Go 1.22?

    Gợi ý trả lời

    defer hoãn lời gọi hàm đến khi hàm bao ngoài kết thúc (kể cả khi panic), chạy theo thứ tự LIFO. Tham số của lời gọi được tính ngay lúc gặp defer, không phải lúc chạy.

    for i := 0; i < 3; i++ {
        defer fmt.Println(i) // in 2, 1, 0
    }
    
    • Đừng defer f.Close() trong vòng lặp mở hàng nghìn file: chúng chỉ đóng khi hàm kết thúc. Tách thân vòng lặp thành hàm riêng.

    Về vòng lặp: trước Go 1.22, biến vòng lặp dùng chung cho mọi lần lặp nên goroutine hay closure thường thấy giá trị cuối cùng. Từ Go 1.22, mỗi lần lặp có biến riêng (khi go.mod khai báo 1.22 trở lên), nên lỗi này không còn.

    Người phỏng vấn muốn nghe: bạn biết thay đổi này phụ thuộc phiên bản trong go.mod.

  9. 09Goroutine leak là gì? Làm sao phát hiện và phòng tránh?Senior

    Gợi ý trả lời

    Goroutine leak là goroutine bị chặn mãi mãi và không bao giờ kết thúc, giữ theo bộ nhớ và tài nguyên nó tham chiếu. Số goroutine tăng dần theo thời gian là dấu hiệu rõ nhất.

    Nguyên nhân hay gặp:

    • Gửi vào channel mà bên nhận đã bỏ đi (ví dụ đã trả về vì timeout).
    • Nhận từ channel không bao giờ được gửi hoặc đóng.
    • Vòng lặp chạy nền không kiểm tra ctx.Done().
    • HTTP response không đóng Body.
    ch := make(chan Result, 1) // buffer 1: goroutine gửi xong vẫn thoát được
    go func() { ch <- slowCall() }()
    select {
    case r := <-ch: return r, nil
    case <-ctx.Done(): return Result{}, ctx.Err()
    }
    

    Phát hiện: theo dõi runtime.NumGoroutine(), xem profile goroutine qua net/http/pprof, dùng thư viện goleak trong test.

    Người phỏng vấn muốn nghe: mỗi goroutine bạn tạo phải có câu trả lời rõ ràng cho câu hỏi "nó kết thúc khi nào?".

  10. 10Generics trong Go dùng thế nào? Khi nào nên dùng generics thay vì interface?Senior

    Gợi ý trả lời

    Từ Go 1.18, hàm và kiểu có thể nhận type parameter với ràng buộc (constraint):

    func Map[T, U any](in []T, f func(T) U) []U {
        out := make([]U, 0, len(in))
        for _, v := range in {
            out = append(out, f(v))
        }
        return out
    }
    
    • Constraint có thể là any, comparable, cmp.Ordered, hoặc interface tự định nghĩa chứa tập kiểu (~int | ~float64).
    • Thư viện chuẩn đã có các package generic như slices và maps.

    Khi nào dùng:

    • Generics: cấu trúc dữ liệu và hàm tiện ích làm việc giống hệt nhau với nhiều kiểu (cache, set, hàm lọc, phân trang).
    • Interface: khi cần hành vi khác nhau cho từng kiểu (lưu vào DB hay vào file, nhiều cổng thanh toán).

    Hay sai: generic hoá mọi thứ ngay từ đầu. Code Go chuộng đơn giản; hãy viết cụ thể trước, khi có hai ba chỗ lặp lại thật sự thì mới trích generic.

  11. 11Một service Go có độ trễ cao và tốn nhiều bộ nhớ. Bạn profile và tối ưu thế nào?Senior

    Gợi ý trả lời

    Đo bằng pprof trước khi sửa. Import net/http/pprof (đặt ở cổng nội bộ) rồi dùng go tool pprof để xem CPU profile, heap, allocs, goroutine và block/mutex profile.

    Các hướng tối ưu thường gặp:

    • Giảm cấp phát (allocation): cấp phát nhiều làm GC chạy nhiều. Cấp phát trước cap cho slice và map, tránh chuyển đổi []byte/string thừa, dùng strings.Builder.
    • Escape analysis: go build -gcflags=-m cho biết biến nào bị đẩy lên heap.
    • sync.Pool để tái sử dụng buffer ở đường xử lý nóng.
    • Tinh chỉnh GC: GOMEMLIMIT đặt giới hạn bộ nhớ mềm (hữu ích trong container), GOGC đổi tần suất GC.
    • Viết benchmark (go test -bench . -benchmem) để so sánh trước và sau khi sửa.

    Đừng quên ngoài code: truy vấn DB chậm, connection pool quá nhỏ, gọi API ngoài không timeout.

    Người phỏng vấn muốn nghe: bạn đọc được flame graph và dựa vào số liệu, không tối ưu theo cảm giác.

  12. 12Cần xử lý 10.000 job (gọi API ngoài) với tối đa 20 job chạy cùng lúc, dừng sớm nếu có lỗi. Bạn thiết kế thế nào?Senior

    Gợi ý trả lời

    Đây là bài worker pool có giới hạn concurrency. Cách gọn nhất là errgroup (package golang.org/x/sync/errgroup):

    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(20)
    for _, job := range jobs {
        g.Go(func() error { return process(ctx, job) })
    }
    if err := g.Wait(); err != nil { return err }
    
    • SetLimit khiến g.Go chặn lại khi đã đủ 20 goroutine đang chạy.
    • Lỗi đầu tiên sẽ huỷ ctx; các job khác phải tôn trọng ctx (truyền vào HTTP request) để dừng sớm.

    Cách cổ điển: tạo 20 worker đọc từ một channel job, gửi kết quả qua channel khác, đóng channel đúng thứ tự.

    Cần nghĩ thêm: rate limit phía API ngoài (golang.org/x/time/rate), retry có backoff, có cần dừng hết khi một job lỗi hay chỉ ghi lại lỗi rồi tiếp tục.

    Hay sai: tạo 10.000 goroutine cùng lúc gọi API ngoài, làm cạn connection và bị đối tác chặn.

Gặp câu hỏi khác khi phỏng vấn? Kể lại ẩn danh để người sau ôn đúng trọng tâm, hoặc xem câu hỏi thật hay gặp ở nhiều công ty.