DEV Community

reluth
reluth

Posted on • Updated on

Thought about Goroutines Lifecycle

#go

Go is reference to a programing language but more, It also reference to keyword to trigger goroutine, a clear and best feature in go programing language make this language strong in concurreny.

Green Threads / M:N Scheduling

Goroutines are often referred to as "green threads." This is because they are not directly mapped to OS threads. Instead, the Go runtime contains its own scheduler that maps a potentially large number of goroutines onto a much smaller number of OS threads. This is known as M:N threading, where M represents goroutines and N represents system threads.

In the M:N threading model that Go utilizes, M denotes Goroutines while N represents system threads. The Go runtime scheduler manages these Goroutines using three key concepts:

  • M (Machine): Refers to an OS thread. It executes code and needs a P to run any G.
  • P (Processor): A context for scheduling that holds a queue of Goroutines that need to be executed. The P needs to be attached to an M to run Goroutines.
  • G (Goroutine): The actual Goroutine that holds the stack and the function to execute.

Goroutine Lifecycle

The Go runtime manages the lifecycle of a goroutine through several stages, from creation to termination:

1. Creation

  • go Keyword: A goroutine is created by using the go keyword followed by a function call. This signals the Go runtime to initialize a new goroutine.
  go myFunction()
Enter fullscreen mode Exit fullscreen mode

Launching a goroutine (reference: proc.go): When you start a goroutine with go keyword, internally, the newproc function gets called from the proc.go file. This function creates a new G (goroutine), initializes it, and puts it to the local run queue of the current P (processor). If there's no P available, it puts the G into the global queue.

// Create a new G(goroutine) running function fn.
// Put the newly created G(goroutine) on the queue of G's waiting for a P(processor) to run.
// The compiler turns a 'go' statement into a call to this function.
func newproc(fn *funcval) {
    // Get the current G(goroutine).
    gp := getg()

    // Get the address of the function that caused 'newproc' to be called for tracing.
    pc := getcallerpc()

    // Switch from the G(goroutine) stack to the system stack, this is required because some functions in Goroutine creation must run on system stack.
    systemstack(func() {
        // Create the new G(goroutine).
        newg := newproc1(fn, gp, pc)

        // Get the current P(processor).
        pp := getg().m.p.ptr()

        // Add the new G(goroutine) to the queue of G's maintained by P(processor).
        runqput(pp, newg, true)

        // If the main G(goroutine) has started, potentially wake up another M(OS thread).
        if mainStarted {
            wakep()
        }
    })
}
Enter fullscreen mode Exit fullscreen mode
  • Stack Allocation: The runtime allocates a small initial stack for the goroutine, which can grow or shrink as needed. This stack is separate from the OS thread stack.

See about newproc1 invoke by newproc func, the piece of code that deals with stack allocation for the new goroutine is:

// 'gfget' tries to get a free G(goroutine) from the free list of the P(processor) that is executing on the current M(machine).
newg := gfget(pp)

// If there is no free G(goroutine) available,
if newg == nil {

    // then create a new G(goroutine) using 'malg' function with a minimal stack size.
    newg = malg(stackMin)

    // The status of new G(goroutine) is changed from _Gidle to _Gdead.
    // _Gidle -> G is unused(empty), _Gdead -> G is exited, and waiting to be cleaned up.
    casgstatus(newg, _Gidle, _Gdead)

    // Add this new G(goroutine) to the list of all G's(goroutines).
    allgadd(newg)
}
Enter fullscreen mode Exit fullscreen mode

2. Scheduling

  • Goroutine States: Goroutines pass through various states such as runnable, running, waiting, or syscall during their lifecycle.

Prefer to go runtime2.go these are 7 status about goroutine, exclude 2 unused status _Genqueue_unused and _Gmoribund_unused

const (
    _Gidle           = iota // 0 : goroutine is idle
    _Grunnable              // 1 : goroutine is runnable (on a run queue)
    _Grunning               // 2 : goroutine is running (executing)
    _Gsyscall               // 3 : goroutine is performing a syscall 
    _Gwaiting               // 4 : goroutine is waiting for the runtime
    _Gdead                  // 6 : goroutine is dead
    _Gcopystack             // 8 : stack being copied to implement a grow or shrink operation
        _Gpreempted             // 9 : goroutine is preempted
)
Enter fullscreen mode Exit fullscreen mode

In proc.go use casgstatus function to switch between gouroutine state A to B see more here, some sample like:

Make a goroutine to runnable

// Mark gp ready to run.
func ready(gp *g, traceskip int, next bool) {
    ....
    // status is Gwaiting or Gscanwaiting, make Grunnable and put on runq
    casgstatus(gp, _Gwaiting, _Grunnable)
Enter fullscreen mode Exit fullscreen mode

Execute a machine

// Schedules gp to run on the current M.
func execute(gp *g, inheritTime bool) {
    ....
    // Assign gp.m before entering _Grunning so running Gs have an
    // M.
    mp.curg = gp
    gp.m = mp
    casgstatus(gp, _Grunnable, _Grunning)
Enter fullscreen mode Exit fullscreen mode

After link all source code we have a state diagram:

Goroutine state diagram

  • M:N Scheduling: The Go runtime implements an M:N scheduler that multiplexes M goroutines onto N OS threads, allowing for efficient use of resources.

mstart() and mstart1() functions define how an OS thread (or 'M') begins execution.

// mstart is the entry-point for new Ms.
// It is written in assembly, uses ABI0, is marked TOPFRAME, and calls mstart0.
func mstart()
Enter fullscreen mode Exit fullscreen mode
func mstart1() {
  .............
  schedule()
}
Enter fullscreen mode Exit fullscreen mode

It requires a 'P' (processor) to execute any goroutines. The schedulers/threads (denoted as 'M') are created and executed in schedule function.

func schedule() {
    pp := _g_.m.p.ptr()
    pp.preempt = false
        gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available
    // If about to schedule a not-normal goroutine (a GCworker or tracereader),
    // wake a P if there is one.
    if tryWakeP {
        wakep()
    }
    execute(gp, inheritTime)
}
Enter fullscreen mode Exit fullscreen mode
  • Goroutine Queues: Runnable goroutines are placed in a local run queue or a global run queue, waiting for an available logical processor (P) to execute them.

3. Execution

  • Context Switching: The runtime switches between goroutines when a goroutine makes a blocking system call, an explicit yield happens (like runtime.Gosched()), or it completes a predefined quantum of execution time.

Go runtime provides a function called Gosched() in the runtime package for explicitly yielding processor time to other goroutines.

// Gosched yields the processor, allowing other goroutines to run. It does not
// suspend the current goroutine, so execution resumes automatically.
//
//go:nosplit
func Gosched() {
    checkTimeouts()
    mcall(gosched_m)
}

// Implementation of gosched_m:
// Gosched continuation on g0.
func gosched_m(gp *g) {
    if traceEnabled() {
        traceGoSched()
    }
    goschedImpl(gp)
}

// Implementation of goschedImpl:
func goschedImpl(gp *g) {
    status := readgstatus(gp)
    if status&^_Gscan != _Grunning {
        dumpgstatus(gp)
        throw("bad g status")
    }
    casgstatus(gp, _Grunning, _Grunnable)
    dropg()
    lock(&sched.lock)
    // pushes the current goroutine into the global queue
    globrunqput(gp)
    unlock(&sched.lock)
    // schedules another goroutine to run
    schedule()
}
Enter fullscreen mode Exit fullscreen mode

This function calls mcall(gosched_m), which pushes the current goroutine into the global queue (globrunqput(gp)) and schedules another goroutine to run (schedule()). There is another way to call this process is Explicit Yielding

4. Blocking

  • System Calls & Channels: When a goroutine performs a blocking operation like I/O, network communication, or channel operations, it enters a waiting state and yields the processor to other goroutines.

Under the hood, the Go runtime uses epoll on Linux, kqueue for BSDs and OS X, WaitForMultipleObjects on Windows, and *select"⇢ on other platforms to perform non-blocking I/O. When a goroutine enters a syscall (for example I/O operation via the net package), it calls into the scheduler, announcing that it's not runnable anymore which is handled by gopark.

// Puts the current goroutine into a waiting state and calls unlockf on the
// system stack.

func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceReason traceBlockReason, traceskip int) {
    if reason != waitReasonSleep {
        checkTimeouts() // timeouts may expire while two goroutines keep the scheduler busy
    }
    mp := acquirem()
    gp := mp.curg
    status := readgstatus(gp)
    if status != _Grunning && status != _Gscanrunning {
        throw("gopark: bad g status")
    }
    mp.waitlock = lock
    mp.waitunlockf = unlockf
    gp.waitreason = reason
    mp.waitTraceBlockReason = traceReason
    mp.waitTraceSkip = traceskip
    releasem(mp)
    // can't do anything that might move the G between Ms here.
    mcall(park_m)
}
Enter fullscreen mode Exit fullscreen mode

While the goroutine waits for the I/O operation to complete, the Go runtime scheduler can run other goroutines on the same thread. At the end of the system call, goready is called which marks the goroutine as ready.

  • Goroutine Parking: The runtime "parks" the goroutine, potentially moving it to a wait queue associated with the resource it is waiting on.

5. Resuming

  • Ready to Run: Once the blocking condition is resolved (for example, I/O completion or channel availability), the goroutine transitions back to the runnable state and waits to be scheduled on an OS thread.

6. Termination

  • Function Completion: A goroutine terminates naturally when its main function returns.
  • Panic Recovery: If a panic occurs and is not recovered within the goroutine, it will cause the goroutine to terminate. In contrast, a recovered panic may allow the goroutine to continue executing or clean up gracefully.
  • Runtime Exit: When the main function finishes or os.Exit is called, the runtime will terminate all goroutines regardless of their state.

7. Cleanup

  • Stack De-allocation: Once a goroutine has finished executing, its stack is reclaimed by the runtime.
  • Resources Release: Any other resources held by the goroutine are released or finalized during the termination process.

Reference

深入golang runtime的调度

go runtime schedule 原理 - 知乎

Scalable Go Scheduler Design Doc

Top comments (0)