What is goroutine?

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

การพูดคุยแบบสบายๆ

Golang เขียนได้สนุกมาก ยกเว้นการตรวจสอบ if err != nil ที่น่าเบื่อ หนึ่งในเหตุผลพื้นฐานของความสนุกคือ goroutine ซึ่งเป็นคุณสมบัติหลักของ Golang การทำความเข้าใจ goroutines อย่างละเอียดนั้นคุ้มค่า เนื่องจากมีส่วนช่วยอย่างมากต่อความเพลิดเพลินในการทำงานกับ Golang ดังนั้น มาพูดคุยเกี่ยวกับ goroutines กัน โดยหวังว่าจะให้ข้อมูลเชิงลึกแก่ผู้อ่าน

TL;DR: เราจะเริ่มต้นด้วยการพูดคุยเกี่ยวกับการประกอบคอมพิวเตอร์ จากนั้นจะเจาะลึกแนวคิดบางอย่างของระบบปฏิบัติการ เช่น กระบวนการและเธรด ก่อนที่จะสำรวจ goroutines เอง

การประกอบคอมพิวเตอร์

เรามาโฟกัสที่คอมพิวเตอร์ตั้งโต๊ะ ไม่ใช่แล็ปท็อป ลองนึกถึงส่วนประกอบทั่วไปของคอมพิวเตอร์ตั้งโต๊ะ: จอภาพ คีย์บอร์ด เมาส์ และหน่วยกลาง ตอนนี้ มาดูหน่วยกลางกันอย่างใกล้ชิด ซึ่งโดยทั่วไปประกอบด้วยส่วนประกอบที่สำคัญหลายอย่าง: เมนบอร์ด CPU การ์ดแสดงผล (ผู้ที่ชื่นชอบมักเลือกใช้การ์ดแสดงผลเฉพาะ แต่ผู้ใช้ที่มีงบประมาณจำกัดมักพึ่งพาการ์ดแสดงผลแบบรวมใน CPU :( ) ฮาร์ดไดรฟ์ และโมดูลหน่วยความจำ

เรามาใส่ใจกับ CPU และโมดูลหน่วยความจำ เริ่มต้นด้วยแผนภาพโครงร่างของ CPU:

CPU ทำงานอย่างไร? CPU เหมือนคนโง่ๆ มันมีชุดคำสั่งคงที่ เราเลือกคำสั่งบางคำสั่งจากชุดนั้น จัดเรียงคำสั่งเหล่านั้น และป้อนให้กับ CPU มันจะปฏิบัติตามอย่างเชื่อฟัง เคลื่อนไปทางทิศตะวันออกเมื่อได้รับคำสั่ง และไปทางทิศตะวันตกเมื่อได้รับคำสั่ง CPU ประกอบด้วยสองส่วนหลัก: หน่วยเลขคณิตและตรรกะ (ALU) และหน่วยควบคุม ส่วนประกอบทั้งสองนี้ประกอบด้วยรีจิสเตอร์เฉพาะต่างๆ

ลองพิจารณาการดำเนินการ 1 + 1 = 2:

  1. ดึง 1 จากหน่วยความจำและเก็บไว้ในรีจิสเตอร์ A
  2. ดึง 1 อีกตัวจากหน่วยความจำและเก็บไว้ในรีจิสเตอร์ B
  3. ดำเนินการบวกและเขียนผลลัพธ์ไปยังรีจิสเตอร์ A
  4. คัดลอกผลลัพธ์ในรีจิสเตอร์ A ไปยังตำแหน่งหน่วยความจำที่เก็บผลลัพธ์ไว้

ตอนนี้ มาดูการต่อสตริง เช่น "hello" + "world" = "helloworld":

  1. ดึงสตริง "hello" จากหน่วยความจำและคำนวณความยาว (5)
  2. ดึงสตริง "world" จากหน่วยความจำและคำนวณความยาว (5)
  3. บวกความยาวและจัดสรรบล็อกหน่วยความจำที่มีขนาดเหมาะสม (ที่ใดที่หนึ่ง)
  4. คัดลอก "hello" ไปยัง 5 ช่องแรกของหน่วยความจำที่จัดสรร (ที่ใดที่หนึ่ง)
  5. คัดลอก "world" ไปยังช่องที่ 6-10 ของหน่วยความจำที่จัดสรร (ที่ใดที่หนึ่ง)
  6. ส่งคืนที่อยู่ของหน่วยความจำที่จัดสรร (ที่ใดที่หนึ่ง)

นี่เป็นวิธีที่ CPU ดำเนินการเหล่านี้หรือไม่? ในแง่ของรายละเอียดระดับต่ำที่เฉพาะเจาะจง ไม่ใช่ทั้งหมด เพราะมันจัดการกับกลุ่มของ 0 และ 1 อย่างไรก็ตาม ในเชิงนามธรรม ใช่ ระบบสถาปัตยกรรม von Neumann ทั้งหมด รวมถึงสถาปัตยกรรม CPU เฉพาะ เช่น Intel และ ARM รวมถึงเครื่องเสมือน เช่น JVM และ switch...case.. ขนาดใหญ่ ใน eval.c ของ Python (เนื่องจากเครื่องเสมือนจำลอง CPU ทางกายภาพ) ทำตามกระบวนการที่คล้ายคลึงกัน

ตอนนี้เราเข้าใจวิธีการทำงานของ CPU แล้ว มาสำรวจแนวคิดบางอย่างของระบบปฏิบัติการกัน

การติดตั้งระบบปฏิบัติการ

ถึงเวลาติดตั้งระบบแล้ว มันน่ากลัว หลังจากทำตามขั้นตอนไม่กี่ขั้นตอน Linux ก็พร้อมใช้งานแล้ว แต่ทำไมเราถึงต้องการระบบปฏิบัติการ? หากไม่มีมัน ชีวิตของโปรแกรมเมอร์จะท้าทายมากขึ้น ซึ่งเกี่ยวข้องกับงานต่างๆ เช่น:

  1. ศึกษาชุดคำสั่งของ CPU ที่ซื้อมาและอ่านคู่มือ
  2. ตรวจสอบ 512 ไบต์แรกของฮาร์ดไดรฟ์เพื่อทำความเข้าใจการแบ่งพาร์ติชัน
  3. ค้นหาโปรแกรมที่ต้องการที่เริ่มต้นที่ 1024 ไบต์และโหลดลงในหน่วยความจำ
  4. ตระหนักว่าหน่วยความจำมีขนาดเล็กเกินไป โหลดเฉพาะบางส่วนของโปรแกรม
  5. ลองดำเนินการง่ายๆ เช่น 1+1=2 แต่บังเอิญเขียนทับโปรแกรมอื่น
  6. รีบูตและเริ่มต้นใหม่

โชคดีที่ในช่วงไม่กี่ทศวรรษที่ผ่านมา โปรแกรมเมอร์ที่ยอดเยี่ยมได้พัฒนา ระบบปฏิบัติการ ช่วยเราให้พ้นจากความยากลำบากเหล่านั้น

ดังที่ได้กล่าวไว้ก่อนหน้านี้ โปรแกรมของเราอยู่บนฮาร์ดไดรฟ์ ในการเรียกใช้ เราต้องอ่านโปรแกรมจากฮาร์ดไดรฟ์ไปยังหน่วยความจำ จากนั้นจึงดำเนินการคำสั่งเฉพาะ หากฉันจะเขียนระบบปฏิบัติการ ฉันจะจัดระเบียบข้อมูลทั้งหมดของโปรแกรมไว้ในที่เดียวเพื่อการจัดการที่มีประสิทธิภาพ มันจะดูเหมือนนี้:

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

นี่คือกระบวนการ มาดูเลย์เอาต์หน่วยความจำของกระบวนการใน Linux กัน:

อย่างไรก็ตาม ต่อมาผู้คนพบว่ากระบวนการดูจะใหญ่เกินไป ดังนั้นแนวคิดของเธรดจึงเกิดขึ้น นี่คือเลย์เอาต์หน่วยความจำของเธรดใน Linux:

เราจะอธิบายเฉพาะเลย์เอาต์หน่วยความจำของกระบวนการเท่านั้น ผู้อ่านสามารถเข้าใจเลย์เอาต์หน่วยความจำของเธรดด้วยตนเองได้ เมื่อดูแผนภาพเลย์เอาต์หน่วยความจำของกระบวนการ จะมีแนวคิดหลายอย่าง:

  • text: เหล่านี้คือคำสั่งที่กล่าวถึงก่อนหน้านี้ มันคือโค้ดของเรา คำสั่งที่สร้างขึ้นหลังจากการคอมไพล์
  • data: สิ่งนี้รวมถึงตัวแปรที่เริ่มต้นในโค้ด ตัวอย่างเช่น int i = 1
  • bss: สิ่งนี้ใช้สำหรับตัวแปรในโค้ดที่ไม่ได้เริ่มต้น ตัวอย่างเช่น int a
  • heap: เราจะพูดคุยเกี่ยวกับ heap ในไม่ช้า
  • stack: เราจะพูดคุยเกี่ยวกับ stack ในไม่ช้า

ก่อนอื่น ลองคิดดูว่าทำไมเราถึงต้องการ stack ลักษณะเฉพาะของโครงสร้างข้อมูล stack คือ last in, first out (LIFO) ใช่ไหม? นี่เป็นวิธีการทำงานของการเรียกใช้ฟังก์ชันอย่างแน่นอนใช่ไหม? ตัวอย่างเช่น:

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

func main() {
    foo()
}

ลำดับการดำเนินการของโค้ดนี้คือ main ถูกดำเนินการก่อน ตามด้วย foo จากนั้น println, println ส่งคืน, foo ส่งคืน และในที่สุด เนื่องจากไม่มีโค้ดอื่นใน main จึงส่งคืน

คุณเห็นไหม? main คือฟังก์ชันที่ถูกเรียกใช้ก่อน แต่เป็นฟังก์ชันสุดท้ายที่ส่งคืน (ในตอนนี้ ให้เราละเลยโค้ดอื่นใน runtime ชั่วคราว) ในสแต็กการเรียกใช้ฟังก์ชัน การเรียกใช้ฟังก์ชันแต่ละครั้งจะถูกวางไว้ในโครงสร้างข้อมูลที่เรียกว่าเฟรม

เรามาสร้างกระบวนการเรียกใช้ฟังก์ชันข้างต้นใหม่ (ในแง่ของการแสดงในหน่วยความจำ):

แรก ฟังก์ชันใน runtime เรียกใช้ฟังก์ชันหลัก:

| runtime | main

จากนั้น ฟังก์ชันหลักเรียกใช้ foo:

| runtime | main | foo

ถัดไป ฟังก์ชัน foo เรียกใช้ println:

| runtime | main | foo | println

หลังจากนั้น println ส่งคืน:

| runtime | main | foo

ต่อจากนั้น foo ส่งคืน:

| runtime | main

สุดท้าย main ส่งคืน:

| runtime

อย่างที่คุณเห็น หลังจากฟังก์ชันส่งคืน ตัวแปรและข้อมูลอื่นๆ ที่มีอยู่ในฟังก์ชันจะไม่มีอีกต่อไป (ในทางเทคนิคแล้ว ยังคงมีอยู่ แต่โดยทั่วไป โปรแกรมที่ดีจะไม่เข้าถึง) ดังนั้น ถ้าเรามีบางอย่างที่เราไม่ต้องการสูญเสียหลังจากฟังก์ชันส่งคืนล่ะ? กล่าวอีกนัยหนึ่ง เราต้องการให้ข้อมูลบางอย่างเป็นอิสระจากวงจรชีวิตของการเรียกใช้ฟังก์ชัน นี่คือที่ที่ heap ของเราเข้ามามีบทบาท

heap มีไว้เพื่อจุดประสงค์นี้ หน่วยความจำชนิดใดที่จัดสรรโดยโค้ดจะลงเอยใน heap? ถ้าเป็นโค้ด C หน่วยความจำที่จัดสรรโดย malloc จะลงเอยใน heap ถ้าเป็น Golang หลังจากการวิเคราะห์การหลบหนี สิ่งต่างๆ เช่น:

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

ฟังก์ชัน f จะถูกจัดสรรบน heap ทำไม? ถ้า f ถูกจัดสรรบน stack มันจะหายไปหลังจากฟังก์ชัน open ส่งคืน ผู้เรียกจะสามารถใช้งานต่อไปได้อย่างไร? เมื่อเข้าใจสิ่งนี้แล้ว ตอนนี้เรามาดู goroutines กัน

Goroutine

Golang เขียนขึ้นครั้งแรกใน C และต่อมาได้ใช้ bootstrap ของตัวเองใน Golang ตอนนี้ ลองคิดดู ถ้าเราจะใช้ goroutines เอง เราจะทำอย่างไร?

ใจเย็นๆ! Goroutine คืออะไร? เรายังไม่ได้ชี้แจงเรื่องนั้นเลย Goroutine ใน Go คือ coroutine ในคำง่ายๆ coroutine คือหน่วยการดำเนินการที่เล็กที่สุดที่ผู้ใช้กำหนดการ ไม่ใช่ระบบปฏิบัติการ เธรดอย่างที่เรารู้จัก คือหน่วยที่เล็กที่สุดที่ระบบปฏิบัติการกำหนดการ Coroutine คือหน่วยการดำเนินการที่เล็กที่สุดที่โปรแกรมเมอร์กำหนดการด้วยตนเอง พวกมันคล้ายกับการเรียกใช้ฟังก์ชัน แต่ความแตกต่างที่สำคัญคือ coroutine หลายตัวสามารถบันทึกสถานะของพวกมันได้ ในขณะที่ในการเรียกใช้ฟังก์ชัน เมื่อฟังก์ชันออกจากสถานะจะหายไป

ดังนั้น เราไม่สามารถใช้ stack ที่จัดสรรโดยระบบปฏิบัติการได้ใช่ไหม? แต่ฟังก์ชันต้องมี stack สำหรับการดำเนินการ ดังนั้น เราจึงจัดสรรบล็อกหน่วยความจำใน heap และใช้เป็น stack สิ่งนั้นควรใช้งานได้ใช่ไหม? นี่คือเหตุผลที่โครงสร้างของ goroutine ดูเหมือนนี้:

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 ใช้สถาปัตยกรรม GMP ในการใช้ goroutines

ดังที่ได้กล่าวไว้ก่อนหน้านี้ สถาปัตยกรรม von Neumann อาศัยองค์ประกอบเหล่านี้ สิ่งต่างๆ เช่น stack และ heap จำเป็นสำหรับการใช้การเปลี่ยนสถานะ เนื่องจาก goroutine เป็นหน่วยที่เล็กที่สุดที่โปรแกรมเมอร์สามารถกำหนดการได้ จึงควรมี stack และ heap ของตัวเอง heap สามารถแชร์ระหว่างเธรดต่างๆ ในกระบวนการได้ แต่เธรดแต่ละเธรดมี stack ของตัวเอง ป้องกันการใช้งานร่วมกัน ดังนั้น เราจึงจัดสรรบล็อกหน่วยความจำใน heap เพื่อใช้เป็น stack สำหรับ goroutines นั่นคือสาระสำคัญของ goroutines

เอกสารอ้างอิง

EXPLANATION  GOLANG  GOROUTINE 

       

  RELATED


  0 COMMENT


No comment for this article.



  RANDOM FUN

Cat overflow