Facebook Pixel

Concurrency pattern trong Golang - Phần 1

25 Mar, 2024

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.

Concurrency pattern trong Golang - Phần 1

Mục Lục

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

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

Go
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

Go
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:

Go
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:

Go
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

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

Go
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à ngă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 hoclexical.

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:

Bài viết liên quan

Lập trình backend expressjs

xây dựng hệ thống microservices
  • Kiến trúc Hexagonal và ứng dụngal font-
  • TypeScript: OOP và nguyên lý SOLIDal font-
  • Event-Driven Architecture, Queue & PubSubal font-
  • Basic scalable System Designal font-

Đăng ký nhận thông báo

Đừng bỏ lỡ những bài viết thú vị từ 200Lab