What is goroutine?

  sonic0002        2024-01-21 03:26:10       1,402        0          English  简体中文  繁体中文  ภาษาไทย  Tiếng Việt 

Thảo luận thân mật

Golang khá thú vị khi viết, ngoài việc kiểm tra if err != nil tẻ nhạt. Một trong những lý do cơ bản cho sự thú vị này là goroutine, một tính năng cốt lõi của Golang. Hiểu chi tiết về goroutines là điều đáng giá, vì chúng đóng góp đáng kể vào niềm vui khi làm việc với Golang. Vì vậy, hãy cùng thảo luận về goroutines, hy vọng sẽ cung cấp một số hiểu biết cho độc giả.

TL;DR: Chúng ta sẽ bắt đầu bằng cách thảo luận về việc lắp ráp một máy tính, sau đó đi sâu vào một số khái niệm của hệ điều hành, chẳng hạn như tiến trình và luồng, trước khi khám phá chính goroutines.

Lắp ráp một máy tính

Hãy tập trung vào máy tính để bàn, không phải máy tính xách tay. Hãy nghĩ về các thành phần phổ biến của một máy tính để bàn: màn hình, bàn phím, chuột và một đơn vị trung tâm. Bây giờ, hãy xem xét kỹ hơn đơn vị trung tâm, thường bao gồm một số thành phần thiết yếu: bo mạch chủ, CPU, card đồ họa (người đam mê thường chọn card đồ họa chuyên dụng, trong khi người dùng ngân sách thường dựa vào card đồ họa tích hợp trong CPU :( ), ổ cứng và mô-đun bộ nhớ.

Hãy chú ý đến CPU và các mô-đun bộ nhớ. Bắt đầu với sơ đồ CPU:

CPU hoạt động như thế nào? CPU giống như một người đơn giản; nó có một tập hợp hướng dẫn cố định. Chúng ta chọn một số hướng dẫn từ tập hợp đó, sắp xếp chúng và đưa chúng vào CPU. Nó ngoan ngoãn thực hiện chúng, di chuyển về phía đông khi được lệnh và về phía tây khi được hướng dẫn. CPU bao gồm hai thành phần chính: đơn vị logic số học (ALU) và đơn vị điều khiển. Hai thành phần này bao gồm nhiều thanh ghi chuyên dụng.

Hãy xem xét phép toán 1 + 1 = 2:

  1. Lấy 1 từ bộ nhớ và lưu nó vào thanh ghi A.
  2. Lấy thêm 1 từ bộ nhớ và lưu nó vào thanh ghi B.
  3. Thực hiện phép cộng và ghi kết quả vào thanh ghi A.
  4. Sao chép kết quả trong thanh ghi A vào vị trí bộ nhớ nơi lưu trữ kết quả.

Bây giờ, hãy xem xét nối chuỗi, giống như "hello" + "world" = "helloworld":

  1. Lấy chuỗi "hello" từ bộ nhớ và tính độ dài của nó (5).
  2. Lấy chuỗi "world" từ bộ nhớ và tính độ dài của nó (5).
  3. Cộng độ dài và phân bổ một khối bộ nhớ có kích thước phù hợp (ở đâu đó).
  4. Sao chép "hello" vào 5 vị trí đầu tiên của bộ nhớ được phân bổ (ở đâu đó).
  5. Sao chép "world" vào các vị trí 6-10 của bộ nhớ được phân bổ (ở đâu đó).
  6. Trả về địa chỉ của bộ nhớ được phân bổ (ở đâu đó).

Điều này có chính xác là cách CPU thực hiện các thao tác này không? Về các chi tiết cấp thấp cụ thể, không chính xác, vì nó xử lý các cụm 0 và 1. Tuy nhiên, một cách trừu tượng, đúng vậy. Tất cả các hệ thống kiến trúc von Neumann, bao gồm các kiến trúc CPU cụ thể như Intel và ARM, cũng như các máy ảo như JVM và switch...case.. khổng lồ. trong eval.c của Python (vì một máy ảo mô phỏng một CPU vật lý), đều tuân theo một quy trình tương tự.

Bây giờ chúng ta đã hiểu cách CPU hoạt động, hãy khám phá một số khái niệm của hệ điều hành.

Cài đặt hệ điều hành

Đã đến lúc cài đặt hệ thống; điều này thật khó khăn. Sau một vài bước, Linux đã hoạt động. Nhưng tại sao chúng ta cần một hệ điều hành? Nếu không có nó, cuộc sống của một lập trình viên sẽ khó khăn, liên quan đến các tác vụ như:

  1. Nghiên cứu tập hợp hướng dẫn của CPU đã mua và đọc hướng dẫn sử dụng.
  2. Kiểm tra 512 byte đầu tiên của ổ cứng để hiểu phân vùng.
  3. Tìm chương trình mong muốn bắt đầu ở 1024 byte và tải nó vào bộ nhớ.
  4. Nhận ra bộ nhớ quá nhỏ, chỉ tải một phần chương trình.
  5. Thử một phép toán đơn giản như 1+1=2, nhưng vô tình ghi đè lên một chương trình khác.
  6. Khởi động lại và bắt đầu lại.

May mắn thay, trong vài thập kỷ qua, các lập trình viên giỏi đã phát triển các hệ điều hành, giúp chúng ta khỏi những khó khăn như vậy.

Như đã đề cập trước đó, các chương trình của chúng ta nằm trên ổ cứng. Để chạy chúng, chúng ta phải đọc chương trình từ ổ cứng vào bộ nhớ và sau đó thực hiện các hướng dẫn cụ thể. Nếu tôi viết một hệ điều hành, tôi sẽ sắp xếp tất cả thông tin của một chương trình ở một nơi để quản lý hiệu quả. Nó sẽ trông giống như thế này:

type process struct {
    instructions unsafe.Pointer;
    instruction_size int64;
    current_offset int64; // offset of current executing instruction
};

Đây là một tiến trình. Hãy xem bố cục bộ nhớ của một tiến trình trong Linux:

Tuy nhiên, sau này mọi người thấy rằng các tiến trình có vẻ quá lớn. Do đó, khái niệm luồng xuất hiện. Đây là bố cục bộ nhớ của một luồng trong Linux:

Chúng tôi chỉ sẽ giải thích bố cục bộ nhớ của một tiến trình; độc giả có thể tự hiểu bố cục bộ nhớ của một luồng. Nhìn vào sơ đồ bố cục bộ nhớ của một tiến trình, có một số khái niệm:

  • text: Đây là các hướng dẫn đã đề cập trước đó; đó là mã của chúng ta, các hướng dẫn được tạo ra sau khi biên dịch.
  • data: Điều này bao gồm các biến được khởi tạo trong mã, ví dụ: int i = 1.
  • bss: Điều này dành cho các biến trong mã không được khởi tạo, ví dụ: int a.
  • heap: Chúng ta sẽ nói về heap trong một lát.
  • stack: Chúng ta sẽ thảo luận về stack ngay.

Đầu tiên, hãy nghĩ về lý do tại sao chúng ta cần một stack. Đặc điểm của cấu trúc dữ liệu stack là last in, first out (LIFO), phải không? Điều này chính xác là cách các cuộc gọi hàm hoạt động, phải không? Ví dụ:

func foo() {
    println("foo")
}

func main() {
    foo()
}

Trình tự thực thi của mã này chắc chắn là main được thực thi trước, tiếp theo là foo, sau đó là println, println trả về, foo trả về và cuối cùng, vì không có mã nào khác trong main, nên nó trả về.

Bạn có thấy không? main là hàm được gọi đầu tiên, nhưng nó là hàm trả về cuối cùng (tạm thời, hãy tạm thời bỏ qua mã khác trong thời gian chạy). Trong ngăn xếp gọi hàm, mỗi cuộc gọi hàm được đặt trong một cấu trúc dữ liệu được gọi là một khung.

Hãy tái tạo lại quá trình gọi hàm ở trên (về cách biểu diễn của nó trong bộ nhớ):

Đầu tiên, một hàm trong thời gian chạy gọi hàm chính:

| runtime | main

Sau đó, hàm chính gọi foo:

| runtime | main | foo

Tiếp theo, hàm foo gọi println:

| runtime | main | foo | println

Sau đó, println trả về:

| runtime | main | foo

Sau đó, foo trả về:

| runtime | main

Cuối cùng, main trả về:

| runtime

Như bạn thấy, sau khi một hàm trả về, các biến và dữ liệu khác có mặt trong hàm không còn nữa (về mặt kỹ thuật, chúng vẫn tồn tại, nhưng thông thường, các chương trình hoạt động tốt không truy cập vào chúng). Vậy nếu chúng ta có thứ gì đó mà chúng ta không muốn mất sau khi một hàm trả về thì sao? Nói cách khác, chúng ta muốn một số dữ liệu độc lập với vòng đời gọi hàm. Đây là lúc heap của chúng ta phát huy tác dụng.

Heap tồn tại vì mục đích này. Loại bộ nhớ nào được phân bổ bởi mã kết thúc trong heap? Nếu đó là mã C, bộ nhớ được phân bổ bởi malloc sẽ kết thúc trong heap. Nếu đó là Golang, sau phân tích thoát, một cái gì đó như:

func open(name string) (*os.File, error) {
    f := &File{}
    return f
}

Hàm f sẽ được phân bổ trên heap. Tại sao? Nếu f được phân bổ trên stack, nó sẽ biến mất sau khi hàm open trả về. Người gọi làm sao có thể tiếp tục sử dụng nó một cách vui vẻ? Hiểu điều này, bây giờ hãy xem xét goroutines.

Goroutine

Golang ban đầu được viết bằng C và sau đó triển khai bootstrap riêng của nó trong Golang. Bây giờ, hãy nghĩ xem, nếu chúng ta tự triển khai goroutines, chúng ta sẽ làm như thế nào?

Cầm chắc ngựa của bạn! Goroutine là gì? Chúng ta vẫn chưa làm rõ điều đó. Một goroutine trong Go là một coroutine. Nói một cách đơn giản, coroutine là đơn vị thực thi nhỏ nhất được lập lịch bởi người dùng chứ không phải hệ điều hành. Luồng, như chúng ta biết, là các đơn vị nhỏ nhất được lập lịch bởi hệ điều hành. Coroutine là các đơn vị thực thi nhỏ nhất được lập lịch thủ công bởi các lập trình viên. Chúng giống như các cuộc gọi hàm, nhưng sự khác biệt chính là nhiều coroutine có thể lưu trạng thái của chúng, trong khi trong các cuộc gọi hàm, một khi một hàm thoát, trạng thái sẽ bị mất.

Vì vậy, chúng ta chắc chắn không thể sử dụng stack được phân bổ bởi hệ điều hành, phải không? Nhưng các hàm phải có một stack để thực thi. Do đó, chúng ta phân bổ một khối bộ nhớ trong heap và sử dụng nó làm stack. Điều đó nên hoạt động, phải không? Đây là lý do tại sao cấu trúc của một goroutine trông như thế này:

type g struct {
        // Stack parameters.
        // stack describes the actual stack memory: [stack.lo, stack.hi).
        // stackguard0 is the stack pointer compared in the Go stack growth prologue.
        // It is stack.lo+StackGuard normally, but can be StackPreempt to trigger a preemption.
        // stackguard1 is the stack pointer compared in the C stack growth prologue.
        // It is stack.lo+StackGuard on g0 and gsignal stacks.
        // It is ~0 on other goroutine stacks, to trigger a call to morestackc (and crash).
        stack       stack   // offset known to runtime/cgo
        stackguard0 uintptr // offset known to liblink
        stackguard1 uintptr // offset known to liblink

        _panic         *_panic // innermost panic - offset known to liblink
        _defer         *_defer // innermost defer
        m              *m      // current m; offset known to arm liblink
        sched          gobuf
        ...
}

Golang triển khai goroutines bằng kiến trúc GMP.

Như đã đề cập trước đó, kiến trúc von Neumann dựa trên các yếu tố này. Những thứ như stack và heap là cần thiết để triển khai các chuyển đổi trạng thái. Vì goroutine là đơn vị nhỏ nhất mà một lập trình viên có thể lập lịch, nên nó phải có stack và heap riêng. Heap có thể được chia sẻ giữa nhiều luồng trong một tiến trình, nhưng mỗi luồng có stack riêng, ngăn chặn việc sử dụng chung. Do đó, chúng ta phân bổ một khối bộ nhớ trong heap để phục vụ như stack cho goroutines. Đó là bản chất của goroutines.

Tài liệu tham khảo

EXPLANATION  GOLANG  GOROUTINE 

       

  RELATED


  0 COMMENT


No comment for this article.



  RANDOM FUN

Genius sorting algorithm