---
title: "Concurrency pattern trong Golang - Phần 1"
description: "Trong lập trình Go, concurrency pattern là những kỹ thuật thiết kế giúp quản lý và tổ chức code cho các tác vụ chạy đồng thời, tận dụng tối đa khả năng xử lý đồng thời của hệ thống. "
canonical: "https://200lab.io/blog/concurrent-pattern-trong-golang"
published: "2024-03-25T09:22:26Z"
updated: "2026-08-21T07:17:35Z"
authors: ["Trần Nhật Anh"]
tags: ["Golang", "Backend"]
reading_minutes: 10
access: "public"
---

Trong lập trình Golang, **concurrency pattern** là những kỹ thuật thiết kế giúp quản lý và tổ chức code cho các tác vụ chạy đồng thời, tận dụng tối đa khả năng xử lý đồng thời của hệ thống. Các kỹ thuật này giúp giải quyết các vấn đề phức tạp liên quan đến đa luồng một cách hiệu quả và an toàn. Bài viết này sẽ giới thiệu một số **concurrency pattern phổ biến** trong Go.

## 1. Confinement

**Confinement** là một kỹ thuật hoặc phương pháp để hạn chế quyền truy cập hoặc tác động của goroutines hoặc biến đến một phần nhất định của dữ liệu hoặc mã, nhằm mục đích tăng cường tính an toàn và tránh xung đột trong lập trình đồng thời.

Có hai loại confinement chính là **ad hoc** và **lexical**.

### 1.1. Ad hoc confinement

**Ad hoc confinement** dựa vào quy ước về cách sử dụng biến. Giới hạn phạm vi được quy định bởi quy ước chung từ ngôn ngữ, convention trong tổ chức hoặc convention của code base. Nó thường yêu cầu sự đồng thuận trong nhóm lập trình về việc ai có thể viết hoặc đọc một biến nhất định. Việc thực hiện và giám sát việc thực hiện theo quy tắc chung không dễ dàng do cần phải có công cụ phân tích thống kê trên mã nguồn tại thời điểm commit.

**Ví dụ về ad hoc confinement**

```
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	data := make([]int, 0) // Một slice rỗng được tạo ra

	// Chúng ta đồng thuận rằng chỉ goroutine tạo ra từ appendData mới có quyền thêm dữ liệu vào slice
	appendData := func(wg *sync.WaitGroup, data *[]int, value int) {
		defer wg.Done()
		// Ad hoc confinement: chỉ hàm này được quyền mở rộng 'data'
		*data = append(*data, value)
	}

	wg.Add(1)
	go appendData(&wg, &data, 10)

	wg.Add(1)
	go appendData(&wg, &data, 20)

	wg.Wait() // Đợi cho đến khi tất cả goroutines hoàn thành

	fmt.Println(data) // Xuất kết quả: [10 20] hoặc [20 10] tùy vào thứ tự hoàn thành của goroutines
}

```

Trong ví dụ này, `data` là một slice mà được chia sẻ giữa các goroutines. Thay vì sử dụng các cơ chế ngôn ngữ để confine truy cập đến `data`, chúng ta tạo một quy ước (ad hoc) rằng chỉ hàm `appendData` mới có thể thay đổi `data`. Điều này giúp đảm bảo rằng dữ liệu chỉ được thay đổi một cách có kiểm soát, nhưng cũng yêu cầu sự tin tưởng và tuân thủ quy ước giữa các lập trình viên.

Điều quan trọng cần lưu ý là Ad hoc confinement phụ thuộc vào sự tự giác của lập trình viên trong việc tuân thủ các quy ước đặt ra và không có cơ chế ngôn ngữ nào để thực thi những quy ước đó. Điều này làm tăng nguy cơ phát sinh lỗi do việc truy cập không mong muốn vào dữ liệu từ các phần của chương trình không được quy ước cho phép.

### 1.2. Lexical confinement

**Lexical confinement** dựa vào phạm vi của biến để hạn chế quyền truy cập. Biến chỉ có thể được truy cập trong một phạm vi cố định, thường là bên trong một hàm hoặc block code, giúp ngăn chặn sự truy cập không mong muốn từ các phần khác của chương trình.

**Ví dụ về Lexical confinement**

```
func main() {
    var data int
    go func() {
        // chỉ có goroutine này mới có thể truy cập và thay đổi 'data'
        data++
    }()
    // ...
}
```

Trong ví dụ trên, biến `data` được lexical confine bên trong một hàm ẩn danh, nghĩa là chỉ có code bên trong hàm này mới có thể truy cập và thay đổi `data`.

## 2. Vòng lặp `for select`

Trong Go, `for select` kết hợp vòng lặp `for` với câu lệnh `select` cho phép bạn xử lý nhiều operations trên channel một cách non-blocking hoặc blocking.

**Cấu trúc cơ bản của một `for select` như sau:**

```
for {
    select {
    case msg1 := <-channel1:
        // Xử lý msg1
    case msg2 := <-channel2:
        // Xử lý msg2
    case <-done:
        // Break hoặc return để thoát loop
        return
    // Thêm các case khác nếu cần
    default:
    	// Case mặc định
    }
}
```

**Phân tích cấu trúc của `for select`:**

- **`for` Loop**: Vòng lặp vô hạn cho phép `select` statement được thực thi liên tục, tạo điều kiện để chờ và xử lý các sự kiện từ nhiều channels khác nhau.
- **`select` Statement**: Cho phép chờ đợi đồng thời trên nhiều channel operations. Khi một trong các operations sẵn sàng, `select` sẽ thực thi case tương ứng. Nếu có nhiều case sẵn sàng cùng một lúc, `select` sẽ chọn một case một cách ngẫu nhiên.

**Ví dụ minh họa sử dụng `for select` để xử lý đọc từ hai channels:**

```
package main

import (
    "fmt"
    "time"
)

func main() {
    c1 := make(chan string)
    c2 := make(chan string)

    go func() {
        time.Sleep(2 * time.Second)
        c1 <- "one"
    }()
    go func() {
        time.Sleep(1 * time.Second)
        c2 <- "two"
    }()

    for i := 0; i < 2; i++ {
        select {
        case msg1 := <-c1:
            fmt.Println("Received", msg1)
        case msg2 := <-c2:
            fmt.Println("Received", msg2)
        }
    }
}
```

Trong ví dụ trên, có hai goroutines, mỗi goroutine gửi một thông điệp vào một channel sau một khoảng thời gian ngủ nhất định. `for select` loop chờ đợi trên cả hai channels, và in ra thông điệp mỗi khi nhận được. Vòng lặp sẽ kết thúc sau khi nhận đủ số lượng thông điệp nhất định, trong trường hợp này là hai.

**Vòng lặp `for select` rất hữu ích trong các tình huống như:**

- Xử lý I/O không đồng bộ hoặc các tác vụ đồng thời khác.
- Thực hiện timeout cho các operations.
- Dừng goroutines một cách an toàn thông qua các signals.

## 3. Ngăn goroutine leaks

Goroutine là luồng thực thi nhẹ, tương tự như threads nhưng ít tốn kém hơn và được Go runtime quản lý. Chúng chạy trên một số nhỏ threads của hệ điều hành, cho phép hàng ngàn, thậm chí hàng triệu goroutine đồng thời tồn tại mà không gặp phải vấn đề về hiệu suất.

Đọc thêm: [Goroutines là gì? Lập trình concurrency chưa bao giờ dễ như Golang](</blog/goroutines-la-gi>)

Goroutines rất dễ sử dụng và có thể được khởi tạo chỉ bằng cách thêm từ khóa `go` trước khi gọi hàm.

```go
package main

import (
	"fmt"
	"time"
	"sync"
)

// printNumbers sẽ in các số từ 1 đến 5, mỗi lần sau 1 giây
func printNumbers() {
	for i := 1; i <= 5; i++ {
		time.Sleep(1 * time.Second)
		fmt.Println(i)
	}
}

func main() {
	var wg sync.WaitGroup

    // Khởi tạo goroutine chạy hàm printNumbers
    go func(wg &sync.WaitGroup) {
    	defer wg.Done()
    	printNumbers()
    }(&wg)

    wg.Wait() // Chờ cho tới khi goroutine chạy xong

    fmt.Println("Done") // In ra màn hình và kết thúc chương trình
}
```

_Ví dụ về goroutine_

Tuy nhiên, khởi tạo goroutine vẫn tiêu tốn tài nguyên và goroutine không được sử dụng sẽ không được thu gom bởi runtime. Do đó, quản lý lãng phí bộ nhớ khi sử dụng goroutine là điều cần thiết.

Sau đây là ví dụ điển hình về goroutine leak

```go
package main

import (
	"fmt"
)

func main() {
	doWork := func(strings <-chan string) <-chan interface{} {
		completed := make(chan interface{})
		go func() {
			defer fmt.Println("doWork exited.")
			defer close(completed)
			for s := range strings {
				fmt.Println(s)
			}
		}()
		return completed
	}

	doWork(nil)

	fmt.Println("Done.")
}
```

_Ví dụ goroutine leak_

```bash
Done.
```

_Kết quả khi chạy_

Khi chạy đoạn code trên, ta thấy goroutine trong hàm `doWork` sẽ không dừng cho tới khi `main` dừng. Lý do là khi truyền `nil` vào `doWork`, vòng `for` bên trong goroutine sẽ không kết thúc cho tới khi tiến trình dừng, dẫn tới goroutine chiếm bộ nhớ nhưng không thực hiện công việc.

Ở ví dụ trên, vòng đời của process rất ngắn nên bạn không thấy không có vấn đề gì xảy ra. Nhưng trên thực tế, các ứng dụng chạy rất nhiều process phức tạp gọi lẫn nhau, trong khi khởi tạo nhiều goroutine. Dẫn tới, nhiều goroutine thừa còn tồn tại trong một thời gian dài, lãng phí rất nhiều tài nguyên và gây ảnh hưởng tới hiệu suất.

Câu hỏi đặt ra là làm sao để chỉ định goroutine dừng từ bên ngoài nơi gọi chúng?

Khi các**[goroutine](</blog/goroutines-la-gi>)** chạy đồng thời, chúng chỉ dừng trong các trường hợp sau:

1. Khi chúng hoàn thành công việc
2. Khi có lỗi xảy ra và go routine không thể tiếp tục chạy được nữa
3. Khi chúng được chỉ định phải dừng

Trường hợp 1 và 2 đều được Go tự xử lý. Trường hợp 3 thường được tự triển khai bằng cách sử dụng read-only channel `done`.

```
package main

import (
	"fmt"
	"time"
)

func main() {
	// channel `done` được truyền vào hàm `doWork`
	doWork := func(done <-chan interface{}, strings <-chan string) <-chan interface{} {
		terminated := make(chan interface{})
		go func() {
			defer fmt.Println("doWork exited.")
			defer close(terminated)
			for {
				select {
				case s := <-strings:
					fmt.Println(s)
				case <-done:
                	// Nếu done có giá trị hoặc bị đóng thì hàm doWork sẽ dừng
					return
				}
			}
		}()
		return terminated
	}

	done := make(chan interface{})
	terminated := doWork(done, nil)

	go func() {
		// Dừng doWork sau 1 giây
		time.Sleep(1 * time.Second)
		fmt.Println("Canceling doWork goroutine...")
		close(done)
	}()

	<-terminated
	fmt.Println("Done.")
}

```

Ở ví dụ trên, goroutine con có thể được chỉ định dừng ở bên ngoài, giúp việc quản lý goroutine được chặt chẽ hơn, giảm lãng phí tài nguyên.

## 4. Tổng kết

Trong bài **Concurrency pattern trong Go** - Phần 1, chúng ta đã tìm hiểu 3 pattern quan trọng là **confinement, vòng lặp `for select`** và n**găn chặn goroutine leak.**

**Confinement** là một kỹ thuật hoặc phương pháp để hạn chế quyền truy cập hoặc tác động của goroutines hoặc biến đến một phần nhất định của dữ liệu hoặc mã, nhằm mục đích tăng cường tính an toàn và tránh xung đột trong lập trình đồng thời. Có hai loại confinement chính là **ad hoc** và **lexical**.

Trong Go, `for select` kết hợp vòng lặp `for` với câu lệnh `select` cho phép bạn xử lý nhiều operations trên channel một cách non-blocking hoặc blocking.

**Goroutine** là luồng thực thi nhẹ, tương tự như threads nhưng ít tốn kém hơn và được Go runtime quản lý. Bộ nhớ có thể bị chiếm giữ bởi nhiều goroutine khởi tạo nhưng không dùng. Chúng ta có thể dùng `done` channel để đóng goroutine từ bên ngoài nơi gọi chúng khi không cần dùng tới.

Hi vọng bài viết đã cung cấp cho các bạn những kiến thức hữu ích về Go.

## 5. Tài liệu tham khảo

- Concurrency in Go - Katherine Cox-Buday

Bạn có thể tham khảo thêm những bài viết về chủ đề Golang của 200Lab:

- [Golang là gì](</blog/golang-la-gi>)
- [Golang channel là gì? Các ứng dụng trong Go channel](</blog/golang-channel-la-gi>)
- [Golang interface - những lỗi sai phổ biến](</blog/interface-trong-golang-cach-dung-chinh-xac>)
