1 Giới Thiệu về Goroutines

1.1 Khái Niệm Cơ Bản về Đồng Thời và Song Song

Đồng thời và song song là hai khái niệm phổ biến trong lập trình đa luồng. Chúng được sử dụng để mô tả các sự kiện hoặc việc thực thi chương trình có thể xảy ra đồng thời.

  • Đồng thời đề cập đến việc xử lý nhiều nhiệm vụ trong cùng khoảng thời gian, nhưng chỉ có một nhiệm vụ đang thực thi tại bất kỳ thời điểm nào. Các nhiệm vụ nhanh chóng chuyển đổi giữa nhau, tạo ảo tưởng về việc thực thi đồng thời. Đồng thời thích hợp cho các bộ xử lý đơn nhân.
  • Song song đề cập đến việc nhiều nhiệm vụ thực sự thực thi đồng thời cùng một lúc, yêu cầu sự hỗ trợ từ bộ xử lý đa nhân.

Ngôn ngữ Go được thiết kế với sự đồng thời là một trong những mục tiêu chính của nó. Nó đạt được mô hình lập trình đồng thời hiệu quả thông qua Goroutines và Channels. Hệ thống chạy của Go quản lý Goroutines và có thể lập lịch cho các Goroutines này trên nhiều luồng hệ thống để đạt được xử lý song song.

1.2 Goroutines trong Ngôn Ngữ Go

Goroutines là khái niệm cốt lõi để đạt được lập trình đồng thời trong ngôn ngữ Go. Chúng là các luồng nhẹ được quản lý bởi hệ thống chạy của Go. Từ quan điểm của người dùng, chúng tương tự như luồng, nhưng tiêu thụ ít tài nguyên và bắt đầu nhanh hơn.

Đặc điểm của Goroutines bao gồm:

  • Nhẹ: Goroutines chiếm ít bộ nhớ ngăn xếp hơn so với luồng truyền thống, và kích thước ngăn xếp của chúng có thể mở rộng hoặc co lại theo nhu cầu.
  • Ít overhead: Sự overhead cho việc tạo và hủy Goroutines thấp hơn nhiều so với luồng truyền thống.
  • Cơ chế giao tiếp đơn giản: Channels cung cấp cơ chế giao tiếp đơn giản và hiệu quả giữa các Goroutines.
  • Thiết kế không chặn: Goroutines không chặn các Goroutines khác khỏi việc chạy trong một số hoạt động. Ví dụ, trong khi một Goroutine đang chờ các hoạt động I/O, các Goroutines khác có thể tiếp tục thực thi.

2 Tạo và Quản Lý Goroutines

2.1 Cách Tạo một Goroutine

Trong ngôn ngữ Go, bạn có thể dễ dàng tạo một Goroutine bằng cách sử dụng từ khóa go. Khi bạn tiền tố một lời gọi hàm bằng từ khóa go, hàm sẽ thực thi một cách bất đồng bộ trong một Goroutine mới.

Hãy xem xét một ví dụ đơn giản:

package main

import (
	"fmt"
	"time"
)

// Định nghĩa một hàm để in "Xin chào"
func sayHello() {
	fmt.Println("Xin chào")
}

func main() {
	// Bắt đầu một Goroutine mới bằng từ khóa go
	go sayHello()

	// Goroutine chính chờ một khoảng thời gian để cho phép sayHello thực thi
	time.Sleep(1 * time.Second)
	fmt.Println("Hàm chính")
}

Trong đoạn mã trên, hàm sayHello() sẽ được thực thi một cách bất đồng bộ trong một Goroutine mới. Điều này có nghĩa là hàm main() sẽ không đợi cho sayHello() thực hiện trước khi tiếp tục. Do đó, chúng ta sử dụng time.Sleep để tạm dừng Goroutine chính, cho phép câu lệnh in trong sayHello được thực thi. Điều này chỉ là cho mục đích minh họa. Trong thực tế, chúng ta thường sử dụng channels hoặc các phương pháp đồng bộ hóa khác để phối hợp việc thực thi của các Goroutines khác nhau.

Lưu ý: Trong ứng dụng thực tế, không nên sử dụng time.Sleep() để đợi cho một Goroutine hoàn thành, vì đó không phải là một cơ chế đồng bộ hóa đáng tin cậy.

2.2 Cơ Chế Lập Lịch Goroutine

Trong Go, lập lịch của Goroutines được xử lý bởi bộ lập lịch của hệ thống chạy của Go, có trách nhiệm phân bổ thời gian thực thi trên các bộ xử lý logic có sẵn. Bộ lập lịch Go sử dụng công nghệ lập lịch M:N (nhiều Goroutines được ánh xạ vào nhiều luồng hệ điều hành) để đạt hiệu suất tốt trên các bộ xử lý đa lõi.

GOMAXPROCS và Bộ Xử Lý Logic

GOMAXPROCS là một biến môi trường xác định số lượng tối đa CPU có sẵn cho bộ lập lịch runtime, với giá trị mặc định là số lõi CPU trên máy. Hệ thống chạy Go chỉ định một luồng hệ điều hành cho mỗi bộ xử lý logic. Bằng cách thiết lập GOMAXPROCS, chúng ta có thể hạn chế số lõi được sử dụng bởi runtime.

import "runtime"

func init() {
    runtime.GOMAXPROCS(2)
}

Đoạn mã trên đặt tối đa hai lõi để lập lịch Goroutines, ngay cả khi chạy chương trình trên một máy có nhiều lõi hơn.

Hoạt động của Lập Lịch

Lập lịch hoạt động bằng ba đơn vị quan trọng: M (máy), P (bộ xử lý), và G (Goroutine). M đại diện cho máy hoặc luồng, đóng vai trò là sự trừu tượng của các luồng hạt nhân OS. P đại diện cho tài nguyên cần thiết để thực thi một Goroutine. Mỗi P có một hàng đợi Goroutine cục bộ. G đại diện cho một Goroutine cụ thể, bao gồm ngăn xếp thực thi, tập lệnh, và thông tin khác.

Nguyên tắc hoạt động của lập lịch Go là:

  • M phải có một P để thực thi G. Nếu không có P, M sẽ được trả về cho bộ nhớ đệm luồng.
  • Khi G không bị chặn bởi G khác (ví dụ, trong các cuộc gọi hệ thống), nó chạy trên cùng M càng nhiều càng tốt, giúp giữ dữ liệu cục bộ của G 'nóng' để tận dụng bộ nhớ cache CPU hiệu quả hơn.
  • Khi một G bị chặn, M và P sẽ tách ra, và P sẽ tìm kiếm một M mới hoặc đánh thức một M mới để phục vụ G khác.
go func() {
    fmt.Println("Xin chào từ Goroutine")
}()

Mã trên thể hiện việc bắt đầu một Goroutine mới, điều này sẽ thúc đẩy lập lịch viên để thêm G mới này vào hàng đợi để thực thi.

Lập Lịch Chuỗi Goroutines Theo Hình Thức Bắt Buộc

Ở giai đoạn đầu, Go sử dụng lập lịch hợp tác, có nghĩa là Goroutines có thể làm cho Goroutines khác đói nghèo nếu chúng thực thi trong thời gian dài mà không từ bỏ quyền điều khiển tự ý. Bây giờ, lập lịch Go thực hiện lập lịch bắt buộc, cho phép các G chạy lâu dài bị tạm dừng để mở cửa cho G khác thực thi.

2.3 Quản Lý Vòng Đời Goroutine

Để đảm bảo tính mạnh mẽ và hiệu suất của ứng dụng Go của bạn, hiểu và quản lý đúng vòng đời của Goroutines là quan trọng. Bắt đầu Goroutines đơn giản, nhưng mà không quản lý đúng, chúng có thể dẫn đến vấn đề như rò rỉ bộ nhớ và điều kiện đua. Bắt đầu Goroutines một cách an toàn

Trước khi bắt đầu một Goroutine, hãy đảm bảo hiểu rõ khối lượng công việc và đặc tính thời gian chạy của nó. Một Goroutine nên có một khởi đầu và kết thúc rõ ràng để tránh tạo ra "Goroutine mồ côi" mà không có điều kiện kết thúc.

func worker(done chan bool) {
    fmt.Println("Đang làm việc...")
    time.Sleep(time.Second) // giả lập nhiệm vụ tốn kém
    fmt.Println("Hoàn thành công việc.")
    done <- true
}

func main() {
    // Ở đây, cơ chế kênh trong Go được sử dụng. Bạn có thể đơn giản coi kênh như hàng đợi tin nhắn cơ bản, và sử dụng toán tử "<-" để đọc và ghi dữ liệu hàng đợi.
    done := make(chan bool, 1)
    go worker(done)
    
    // Chờ cho Goroutine hoàn thành
    <-done
}

Mã trên cho thấy một cách để chờ một Goroutine hoàn thành bằng cách sử dụng kênh done.

Lưu ý: Ví dụ này sử dụng cơ chế kênh trong Go, sẽ được mô tả chi tiết trong các chương sau.

Dừng Goroutines

Nói chung, việc kết thúc toàn bộ chương trình sẽ chấm dứt tất cả Goroutines ngầm. Tuy nhiên, trong các dịch vụ chạy lâu dài, chúng ta có thể cần dừng các Goroutines một cách tích cực.

  1. Sử dụng kênh để gửi tín hiệu dừng: Goroutines có thể kiểm tra các kênh để kiểm tra tín hiệu dừng.
stop := make(chan struct{})

go func() {
    for {
        select {
        case <-stop:
            fmt.Println("Nhận tín hiệu dừng. Đang tắt...")
            return
        default:
            // thực hiện hoạt động bình thường
        }
    }
}()

// Gửi tín hiệu dừng
stop <- struct{}{}
  1. Sử dụng gói context để quản lý vòng đời:
ctx, cancel := context.WithCancel(context.Background())

go func(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Nhận tín hiệu dừng. Đang tắt...")
            return
        default:
            // thực hiện hoạt động bình thường
        }
    }
}(ctx)

// khi bạn muốn dừng Goroutine
cancel()

Sử dụng gói context cho phép kiểm soát linh hoạt hơn của vòng đời Goroutines, cung cấp khả năng hết thời gian và hủy bỏ. Trong các ứng dụng lớn hoặc dịch vụ micro, context là cách được khuyến nghị để kiểm soát vòng đời Goroutines.