Published on

Một số câu hỏi phỏng vấn golang-p2

Authors
  • avatar
    Name
    Hoàng Hữu Mạnh
    Twitter

Deadlock là gì?

Deadlock là một tình trạng trong lập trình đa luồng khi hai hoặc nhiều luồng (goroutines trong ngôn ngữ Go) bị kẹt lại vì mỗi luồng đang chờ đợi một tài nguyên mà chỉ có thể được giải phóng bởi một trong số các luồng khác. Điều này dẫn đến tình trạng tất cả các luồng đều không thể tiếp tục thực thi và chương trình bị kẹt lại, không thể hoàn thành công việc.

Deadlock xảy ra khi có bốn điều kiện sau đây đồng thời xảy ra:

  1. Holding and waiting: Một luồng đã giữ một tài nguyên (ví dụ: mutex hoặc lock) và đang chờ đợi để lấy tài nguyên khác.
  2. No preemption: Không có cơ chế nào có thể lấy tài nguyên từ một luồng khi nó đã giữ tài nguyên và không cần thiết.
  3. Mutual exclusion: Tài nguyên chỉ có thể được một luồng sử dụng tại một thời điểm.
  4. Circular wait: Có một chuỗi các luồng đang chờ đợi tài nguyên theo cách mà luồng cuối cùng trong chuỗi đang chờ đợi tài nguyên được giữ bởi luồng đầu tiên trong chuỗi.

Một ví dụ đơn giản về deadlock trong Go:

package main

import "sync"

func main() {
    var wg sync.WaitGroup
    ch := make(chan int)

    // Goroutine 1
    go func() {
        wg.Add(1)
        ch <- 42 // Gửi dữ liệu vào channel
        wg.Done()
    }()

    // Goroutine 2
    go func() {
        wg.Add(1)
        <-ch // Nhận dữ liệu từ channel
        wg.Done()
    }()

    wg.Wait()
}

Trong ví dụ này, hai goroutine đang chờ đợi lẫn nhau. Goroutine 1 đang chờ để gửi dữ liệu vào channel ch, trong khi goroutine 2 đang chờ để nhận dữ liệu từ channel đó. Vì cả hai goroutine đều đang chờ đợi tài nguyên được giải phóng bởi goroutine khác mà không bao giờ xảy ra, nên chương trình sẽ bị kẹt lại và gặp phải deadlock.

GRPC là gì?

gRPC (gRPC Remote Procedure Call) là một framework mã nguồn mở được phát triển bởi Google, được sử dụng để tạo các dịch vụ mạng hiệu suất cao và dễ mở rộng. Nó sử dụng giao thức HTTP/2 để truyền tải dữ liệu giữa các ứng dụng và cho phép giao tiếp giữa các ứng dụng chạy trên các máy chủ khác nhau một cách hiệu quả.

Một số đặc điểm quan trọng của gRPC:

  1. RPC (Remote Procedure Call): gRPC cho phép bạn gọi các hàm và phương thức từ các máy chủ từ xa một cách dễ dàng và mạnh mẽ. Bạn có thể gọi các phương thức từ máy chủ từ xa giống như việc gọi các hàm cục bộ.

  2. IDL (Interface Definition Language): gRPC sử dụng Protobuf (Protocol Buffers) làm ngôn ngữ mô tả giao diện cho dịch vụ của mình. Protobuf cho phép bạn định nghĩa các cấu trúc dữ liệu và giao diện API của dịch vụ một cách rõ ràng và linh hoạt.

  3. HTTP/2: gRPC sử dụng giao thức HTTP/2 làm cơ sở truyền tải dữ liệu. HTTP/2 cung cấp nhiều tính năng như kết nối đa luồng, nén dữ liệu, đẩy thông tin và phục hồi các kết nối.

  4. Hỗ trợ nhiều ngôn ngữ: gRPC không chỉ hỗ trợ ngôn ngữ Go, mà còn hỗ trợ nhiều ngôn ngữ lập trình khác nhau như C++, Java, Python, Ruby, C#, JavaScript, và nhiều ngôn ngữ khác.

  5. Khả năng mở rộng và hiệu suất cao: gRPC được thiết kế để hỗ trợ các hệ thống phân tán có khả năng mở rộng cao, giúp bạn xây dựng các dịch vụ mạng linh hoạt và có thể mở rộng lên hàng trăm hoặc hàng nghìn máy chủ.

Với các tính năng như hiệu suất cao, hỗ trợ nhiều ngôn ngữ và sự mạnh mẽ của Protobuf, gRPC là một lựa chọn phổ biến cho việc xây dựng các hệ thống phân tán và dịch vụ mạng trong các ứng dụng hiện đại.

Generics trong golang là gì?

Generics trong ngôn ngữ lập trình Go là một tính năng cho phép bạn viết mã một cách tổng quát cho nhiều loại dữ liệu khác nhau mà không cần phải lặp lại mã nhiều lần cho từng loại dữ liệu cụ thể. Tính năng generics giúp tạo ra mã nguồn dễ đọc, linh hoạt và tái sử dụng.

Trước khi Go 1.18, ngôn ngữ Go không hỗ trợ generics. Tuy nhiên, từ Go 1.18 trở đi, generics đã trở thành một tính năng chính thức của ngôn ngữ Go.

Với generics trong Go, bạn có thể viết các hàm, phương thức, và cấu trúc dữ liệu một cách tổng quát cho nhiều loại dữ liệu khác nhau. Bằng cách sử dụng kiểu tham số (type parameter), bạn có thể khai báo các hàm hoặc phương thức chấp nhận các kiểu dữ liệu chưa được xác định cụ thể.

Dưới đây là một ví dụ về cách sử dụng generics trong Go:

package main

import "fmt"

// Hàm swap sử dụng generics để hoán đổi giá trị của hai biến
func swap[T any](a, b T) (T, T) {
    return b, a
}

func main() {
    // Sử dụng hàm swap với kiểu dữ liệu int
    x, y := swap(10, 20)
    fmt.Println("x =", x, "y =", y)

    // Sử dụng hàm swap với kiểu dữ liệu string
    s1, s2 := swap("hello", "world")
    fmt.Println("s1 =", s1, "s2 =", s2)
}

Trong ví dụ trên, chúng ta đã định nghĩa hàm swap sử dụng generics, cho phép hoán đổi giá trị của hai biến với bất kỳ kiểu dữ liệu nào. Khi gọi hàm swap, chúng ta không cần phải chỉ định kiểu dữ liệu cụ thể, mà Go sẽ suy luận kiểu dữ liệu dựa trên các đối số được truyền vào.

Defer trong go để làm gì?

Trong ngôn ngữ lập trình Go, từ khóa defer được sử dụng để đăng ký một hàm để được gọi sau khi hàm hiện tại hoàn thành. Việc sử dụng defer làm cho hàm đó được gọi dù có lỗi xảy ra trong quá trình thực thi hoặc không, và nó sẽ được thực hiện trước khi hàm hiện tại kết thúc. Điều này thường được sử dụng để đảm bảo rằng các tài nguyên được giải phóng hoặc các thao tác clean-up được thực hiện, ngay cả khi có lỗi xảy ra trong hàm.

Ví dụ:

package main

import "fmt"

func main() {
    defer fmt.Println("Đây là hàm defer 1")
    defer fmt.Println("Đây là hàm defer 2")

    fmt.Println("Thực hiện công việc chính")

    defer fmt.Println("Đây là hàm defer 3")
}

Kết quả sẽ là:

Thực hiện công việc chính
Đây là hàm defer 3
Đây là hàm defer 2
Đây là hàm defer 1

Như bạn có thể thấy, các hàm defer được đăng ký được thực hiện theo thứ tự ngược lại từ vị trí của nó trong mã, tức là hàm defer 3 được gọi trước hàm defer 2 và hàm defer 1.

Defer thường được sử dụng trong các tình huống như đóng các tài nguyên như file hoặc cơ sở dữ liệu, giải phóng bộ nhớ, hoặc thực hiện các thao tác clean-up.

Select trong golang

Trong ngôn ngữ lập trình Go, từ khóa select được sử dụng để lắng nghe và chọn lựa giữa nhiều kênh (channels). select cho phép bạn chờ đợi cho một trong các trường hợp sau xảy ra:

  1. Khi có dữ liệu có thể được đọc từ một hoặc nhiều kênh.
  2. Khi có thể ghi dữ liệu vào một hoặc nhiều kênh.
  3. Khi một hoặc nhiều kênh đã đóng.

Cú pháp của select trong Go như sau:

select {
case <-channel1:
    // Thực hiện hành động khi có dữ liệu có thể được đọc từ channel1
case channel2 <- value:
    // Thực hiện hành động khi dữ liệu có thể được ghi vào channel2
case <-channel3:
    // Thực hiện hành động khi có dữ liệu có thể được đọc từ channel3
default:
    // Thực hiện hành động khi không có trường hợp nào xảy ra
}
  • case <-channel: Đọc dữ liệu từ channel.
  • case channel <- value: Ghi dữ liệu vào channel.
  • default: Lựa chọn mặc định, được thực hiện khi không có trường hợp nào khác xảy ra.

Ví dụ:

package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    go func() {
        time.Sleep(2 * time.Second)
        ch1 <- "Dữ liệu từ kênh 1"
    }()

    go func() {
        time.Sleep(1 * time.Second)
        ch2 <- "Dữ liệu từ kênh 2"
    }()

    select {
    case msg1 := <-ch1:
        fmt.Println("Nhận được:", msg1)
    case msg2 := <-ch2:
        fmt.Println("Nhận được:", msg2)
    }
}

Trong ví dụ trên, chúng ta sử dụng select để lắng nghe và nhận dữ liệu từ hai kênh ch1ch2. Khi một trong các kênh có dữ liệu có thể được đọc, chúng ta nhận được dữ liệu từ kênh đó và in ra nó. Trong trường hợp này, kênh ch2 sẽ được nhận trước vì nó có thời gian ngủ nhỏ hơn.

Khi nào sử dụng panic?

Trong ngôn ngữ lập trình Go, panic là một hàm được sử dụng để tạo ra một lỗi nghiêm trọng (panic) trong quá trình thực thi của một chương trình. Khi một panic xảy ra, chương trình sẽ dừng lại và in ra thông báo lỗi, sau đó kết thúc.

Bạn nên sử dụng panic trong các trường hợp sau:

  1. Lỗi nghiêm trọng không thể khắc phục được: Khi chương trình gặp phải một lỗi mà không thể xử lý hoặc phục hồi được, bạn có thể sử dụng panic để kết thúc chương trình một cách an toàn và tránh tình trạng không nhất quán hoặc hỏng hóc.

  2. Kiểm soát lỗi trong thư viện hoặc gói package: Khi bạn viết một thư viện hoặc gói package và gặp phải một tình huống mà không thể xử lý được, bạn có thể sử dụng panic để thông báo lỗi và yêu cầu người dùng phải kiểm tra lại mã của họ.

  3. Debugging: Trong quá trình phát triển và debug, bạn có thể sử dụng panic để tạo ra một dừng chương trình ngay lập tức khi một điều kiện không mong muốn xảy ra, giúp bạn dễ dàng xác định vị trí và nguyên nhân của lỗi.

Tuy nhiên, bạn nên cẩn thận khi sử dụng panic, vì nó làm dừng chương trình một cách đột ngột và có thể gây ra các vấn đề về tính nhất quán của dữ liệu. Để xử lý các lỗi dễ kiểm soát và có thể phục hồi được, bạn nên sử dụng cơ chế xử lý lỗi (error handling) thay vì sử dụng panic.

Viết unit testing như nào

Viết unit tests trong Go được thực hiện bằng cách sử dụng package testing của Go. Mỗi file test phải được đặt trong cùng package và có tên file kết thúc bằng _test.go. Bên dưới là một ví dụ cách viết unit tests trong Go:

Giả sử chúng ta có một hàm đơn giản để tính tổng của hai số:

// file math.go
package main

func Add(a, b int) int {
    return a + b
}

Và sau đây là file test cho hàm Add:

// file math_test.go
package main

import "testing"

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    expected := 5
    if result != expected {
        t.Errorf("Kết quả mong đợi: %d, Kết quả thực tế: %d", expected, result)
    }
}

Trong ví dụ này:

  • Chúng ta đã import package testing.
  • Chúng ta đã định nghĩa một hàm test mới với tên là TestAdd, theo quy ước của Go, và chuyển vào một tham chiếu của *testing.T.
  • Trong hàm test, chúng ta gọi hàm Add với các giá trị đầu vào và so sánh kết quả với giá trị mong đợi.
  • Nếu kết quả không trùng khớp, chúng ta sử dụng t.Errorf() để báo lỗi với thông điệp tùy chỉnh.

Sau đó, để chạy các test, bạn chỉ cần sử dụng lệnh go test trong thư mục chứa các file test của bạn. Go sẽ tự động tìm kiếm các file test trong thư mục hiện tại và thực thi chúng.

go test

Đây chỉ là một ví dụ cơ bản về việc viết unit tests trong Go. Bạn có thể tạo nhiều tests khác nhau cho các hàm khác nhau và sử dụng các phương thức khác của testing.T để kiểm tra các trường hợp đặc biệt và điều kiện khác nhau.

Các trường hợp memory leak

Memory leak là tình trạng khi một chương trình không giải phóng bộ nhớ đã được cấp phát, dẫn đến việc sử dụng lượng bộ nhớ tăng dần theo thời gian và có thể gây ra các vấn đề nghiêm trọng như giảm hiệu suất hoặc làm cho ứng dụng gặp phải vấn đề "out of memory".

Dưới đây là một số trường hợp phổ biến có thể dẫn đến memory leak trong các chương trình Go:

  1. Không giải phóng resources: Nếu một chương trình mở các tài nguyên như file, kết nối mạng, hoặc các tài nguyên bộ nhớ (ví dụ: slices, maps) nhưng không giải phóng chúng sau khi sử dụng xong, có thể dẫn đến memory leak.

  2. Lặp vô hạn trong goroutine: Nếu một goroutine được khởi tạo và lặp vô hạn mà không giải phóng bất kỳ tài nguyên nào, nó có thể dẫn đến tiêu thụ lượng bộ nhớ ngày càng tăng theo thời gian.

  3. Tham chiếu đệ quy không chấm dứt: Nếu một hàm đệ quy không có điều kiện dừng, nó có thể tạo ra một ngăn xếp (stack) lớn và dẫn đến memory leak.

  4. Tham chiếu chéo (circular reference): Trong một số trường hợp, tham chiếu chéo giữa các đối tượng có thể ngăn hệ thống thu gom rác (garbage collector) giải phóng bộ nhớ, dẫn đến memory leak.

  5. Thiếu giải phóng goroutine: Nếu một goroutine không kết thúc hoặc không được giải phóng, nó có thể giữ một số tài nguyên bên trong nó (ví dụ: tài nguyên như biến hoặc channels), dẫn đến memory leak.

Để phát hiện và giải quyết memory leak trong các chương trình Go, bạn có thể sử dụng các công cụ như pprof, runtime/pprof, hoặc pprof/http để phân tích và giám sát việc sử dụng bộ nhớ của ứng dụng. Đồng thời, bạn cũng nên kiểm tra và xử lý các vấn đề liên quan đến quản lý tài nguyên một cách cẩn thận trong mã của mình.

Thiết kế chức năng graceful shutdown golang

Thiết kế chức năng graceful shutdown trong một ứng dụng Go là quan trọng để đảm bảo rằng ứng dụng của bạn có thể dừng hoạt động một cách an toàn và đồng nhất khi cần thiết, mà không gây ra sự mất mát dữ liệu hoặc trạng thái không nhất quán. Dưới đây là một cách thiết kế chức năng graceful shutdown trong Go:

  1. Sử dụng Context: Sử dụng package context của Go để quản lý quá trình dừng ứng dụng. Bạn có thể tạo một context cha cho toàn bộ ứng dụng và truyền nó vào các goroutine và các hoạt động chính của ứng dụng.

  2. Signal Handling: Lắng nghe các tín hiệu từ hệ thống như SIGINT (Ctrl+C) hoặc SIGTERM (terminate) để xác định khi nào cần dừng ứng dụng. Bạn có thể sử dụng package os/signal để xử lý tín hiệu này.

  3. Thông báo đóng kênh (channel): Tạo một kênh đóng (shutdownChan chẳng hạn) để thông báo cho các goroutine rằng ứng dụng đang dừng và chúng cần phải hoàn thành công việc của mình.

  4. Goroutine Shutdown: Trong các goroutine của ứng dụng, bạn cần định kỳ kiểm tra context và kênh đóng để xác định khi nào cần dừng goroutine đó.

Dưới đây là một ví dụ cụ thể về cách triển khai chức năng graceful shutdown trong một ứng dụng Go:

package main

import (
    "context"
    "fmt"
    "os"
    "os/signal"
    "sync"
    "syscall"
    "time"
)

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    // Kênh để thông báo khi ứng dụng cần dừng
    shutdownChan := make(chan os.Signal, 1)
    signal.Notify(shutdownChan, syscall.SIGINT, syscall.SIGTERM)

    // WaitGroup để đợi tất cả goroutine kết thúc
    var wg sync.WaitGroup

    // Goroutine 1
    wg.Add(1)
    go func() {
        defer wg.Done()
        for {
            select {
            case <-ctx.Done():
                fmt.Println("Goroutine 1: Đóng")
                return
            default:
                fmt.Println("Goroutine 1: Thực hiện công việc")
                time.Sleep(1 * time.Second)
            }
        }
    }()

    // Goroutine 2
    wg.Add(1)
    go func() {
        defer wg.Done()
        for {
            select {
            case <-ctx.Done():
                fmt.Println("Goroutine 2: Đóng")
                return
            default:
                fmt.Println("Goroutine 2: Thực hiện công việc")
                time.Sleep(1 * time.Second)
            }
        }
    }()

    // Chờ sự kích hoạt tín hiệu shutdown
    <-shutdownChan
    fmt.Println("Nhận tín hiệu shutdown")

    // Hủy context để thông báo cho tất cả goroutine dừng
    cancel()

    // Chờ tất cả goroutine kết thúc
    wg.Wait()
    fmt.Println("Đã dừng ứng dụng graceful")
}

Trong ví dụ này, chúng ta sử dụng package contextos/signal để quản lý quá trình dừng ứng dụng. Các goroutine sẽ định kỳ kiểm tra context và kênh đóng để xác định khi nào cần dừng. Khi nhận được tín hiệu shutdown từ hệ thống, chúng ta hủy context và chờ tất cả goroutine kết thúc.

garbage collection

Garbage collection (GC) là một quá trình tự động trong ngôn ngữ lập trình để thu dọn bộ nhớ không sử dụng nữa, giúp giải phóng tài nguyên và tránh memory leak. Trong Go, garbage collection được thực hiện bởi bộ thu gom rác (garbage collector), một phần của runtime của Go.

Cơ chế garbage collection trong Go hoạt động như sau:

  1. Theo dõi các tham chiếu: Garbage collector của Go theo dõi các tham chiếu từ các biến hoạt động và các cấu trúc dữ liệu khác nhau trong ứng dụng.

  2. Phát hiện các đối tượng không thể truy cập: Garbage collector xác định các đối tượng không thể truy cập được từ bất kỳ đoạn mã nào trong chương trình, được gọi là "garbage" hoặc "rác".

  3. Thu dọn bộ nhớ không sử dụng: Sau khi xác định các đối tượng rác, garbage collector thu dọn bộ nhớ bằng cách giải phóng bộ nhớ của các đối tượng rác đó và trả về nó cho hệ thống.

Trong Go, bộ thu gom rác chạy như một tiểu trình đồng bộ và có thể được cấu hình bằng các biến môi trường hoặc các tùy chọn runtime để điều chỉnh thời gian chạy và cấu hình garbage collection.

Garbage collection giúp giảm thiểu việc quản lý bộ nhớ bằng tay và giúp giảm thiểu nguy cơ memory leak trong các ứng dụng lớn. Tuy nhiên, việc thu dọn rác cũng có thể gây ra các gián đoạn (pause) trong quá trình thực thi của ứng dụng, đặc biệt là khi bộ nhớ đầy và cần phải thực hiện quá trình thu dọn rác.