Skip to content

Instantly share code, notes, and snippets.

@felixge
Last active September 17, 2025 11:29
Show Gist options
  • Select an option

  • Save felixge/b31724e945142674fd096a4028b13c55 to your computer and use it in GitHub Desktop.

Select an option

Save felixge/b31724e945142674fd096a4028b13c55 to your computer and use it in GitHub Desktop.

Investigating goroutine state transition

Exported on 9/17/2025 at 11:26:10 GMT+2 from Cursor (1.6.26)


User

Does this systemstack call lead to a call that might transition a goroutine that is blocked on networking to runnable state? Follow the functions inside of it and see if they call into the scheduler/netpoll thing to make that happen.

		systemstack(func() {
			// Mark our goroutine preemptible so its stack
			// can be scanned or observed by the execution
			// tracer. This, for example, lets two mark workers
			// scan each other (otherwise, they would
			// deadlock). We must not modify anything on
			// the G stack. However, stack shrinking is
			// disabled for mark workers, so it is safe to
			// read from the G stack.
			//
			// N.B. The execution tracer is not aware of this status
			// transition and handles it specially based on the
			// wait reason.
			casGToWaitingForSuspendG(gp, _Grunning, waitReasonGCWorkerActive)
			switch pp.gcMarkWorkerMode {
			default:
				throw("gcBgMarkWorker: unexpected gcMarkWorkerMode")
			case gcMarkWorkerDedicatedMode:
				gcDrainMarkWorkerDedicated(&pp.gcw, true)
				if gp.preempt {
					// We were preempted. This is
					// a useful signal to kick
					// everything out of the run
					// queue so it can run
					// somewhere else.
					if drainQ := runqdrain(pp); !drainQ.empty() {
						lock(&sched.lock)
						globrunqputbatch(&drainQ)
						unlock(&sched.lock)
					}
				}
				// Go back to draining, this time
				// without preemption.
				gcDrainMarkWorkerDedicated(&pp.gcw, false)
			case gcMarkWorkerFractionalMode:
				gcDrainMarkWorkerFractional(&pp.gcw)
			case gcMarkWorkerIdleMode:
				gcDrainMarkWorkerIdle(&pp.gcw)
			}
			casgstatus(gp, _Gwaiting, _Grunning)
		})

Cursor

I'll help you trace through the functions called within this systemstack block to see if any of them could transition a goroutine blocked on networking to a runnable state. Let me start by examining the gc drain functions that are called.

Now let me look at the gcDrain function implementation to see what it calls:

Let me search for the pollWork function that's called in idle mode to see if it leads to netpoll:

func pollWork() bool {
	if !sched.runq.empty() {
		return true
	}
	p := getg().m.p.ptr()
	if !runqempty(p) {
		return true
	}
	if netpollinited() && netpollAnyWaiters() && sched.lastpoll.Load() != 0 {
		if list, delta := netpoll(0); !list.empty() {
			injectglist(&list)
			netpollAdjustWaiters(delta)
			return true
		}
	}
	return false
}

Yes, this systemstack call can indeed lead to transitioning goroutines blocked on networking to a runnable state, but only in idle mode.

Here's the call chain:

  1. When pp.gcMarkWorkerMode == gcMarkWorkerIdleMode, it calls gcDrainMarkWorkerIdle()
  2. gcDrainMarkWorkerIdle calls gcDrain with the gcDrainIdle flag
  3. With the gcDrainIdle flag, gcDrain sets check = pollWork
  4. During the drain loop, it periodically calls pollWork() to check if there's other work to do

Looking at the pollWork function:

func pollWork() bool {
    // ... check run queues ...
    if netpollinited() && netpollAnyWaiters() && sched.lastpoll.Load() != 0 {
        if list, delta := netpoll(0); !list.empty() {
            injectglist(&list)
            netpollAdjustWaiters(delta)
            return true
        }
    }
    return false
}

The key part is that pollWork calls netpoll(0) - a non-blocking network poll that:

  • Checks for any goroutines blocked on network I/O that are now ready
  • Returns a list of such goroutines
  • Calls injectglist(&list) to inject them back into the scheduler's run queue, making them runnable

So yes, when the GC mark worker is running in idle mode, it will periodically check netpoll and wake up any goroutines that were blocked on network I/O and are now ready to run. This makes sense because idle workers should yield when there's other work available, including network I/O that has become ready.

The dedicated and fractional modes do not perform this netpoll check - they focus solely on GC marking work.

func pollWork() bool {
    // ... check run queues ...
    if netpollinited() && netpollAnyWaiters() && sched.lastpoll.Load() != 0 {
        if list, delta := netpoll(0); !list.empty() {
            injectglist(&list)
            netpollAdjustWaiters(delta)
            return true
        }
    }
    return false
}

User

Create a small markdown file called gcBgMarkWorker_wakeup.md where you explain how the above. Include links into the code on GitHub for go version go1.25.0. Also include small code snippets where needed. Keep it simple and short.


Cursor

I'll create a concise markdown file explaining how gcBgMarkWorker can wake up goroutines blocked on networking.

see gcBgMarkWorker_wakeup.md

I've created the markdown file gcBgMarkWorker_wakeup.md that explains how the gcBgMarkWorker can wake up goroutines blocked on networking. The file includes:

  1. A clear explanation that this only happens in idle mode
  2. The call chain from gcBgMarkWorkergcDrainMarkWorkerIdlegcDrainpollWork
  3. Key code snippets showing the relevant parts
  4. GitHub links to the exact lines in Go version 1.25.0
  5. A summary of why this design makes sense (idle workers should yield when there's other work)

The document is concise and focuses on the essential mechanism of how network-blocked goroutines can be transitioned to runnable state during GC marking in idle mode.

gcBgMarkWorker Network Wakeup

The gcBgMarkWorker can transition goroutines blocked on networking to a runnable state, but only when running in idle mode.

Call Chain

When the GC mark worker runs in idle mode (mgc.go#L1559-L1560):

case gcMarkWorkerIdleMode:
    gcDrainMarkWorkerIdle(&pp.gcw)

This calls gcDrainMarkWorkerIdle (mgcmark.go#L1119-L1121):

func gcDrainMarkWorkerIdle(gcw *gcWork) {
    gcDrain(gcw, gcDrainIdle|gcDrainUntilPreempt|gcDrainFlushBgCredit)
}

The gcDrainIdle flag causes gcDrain to set up periodic polling (mgcmark.go#L1190-L1191):

if idle {
    check = pollWork
}

Network Poll Check

During marking, gcDrain periodically calls pollWork() (proc.go#L3778-L3794):

func pollWork() bool {
    // ... check run queues ...
    if netpollinited() && netpollAnyWaiters() && sched.lastpoll.Load() != 0 {
        if list, delta := netpoll(0); !list.empty() {
            injectglist(&list)
            netpollAdjustWaiters(delta)
            return true
        }
    }
    return false
}

The netpoll(0) call is non-blocking and:

  • Checks for goroutines blocked on network I/O that are now ready
  • Returns them in a list
  • injectglist adds them back to the scheduler's run queue, making them runnable

Summary

  • Only idle GC workers check for network I/O readiness
  • This ensures idle workers yield CPU when there's other work (including network I/O)
  • Dedicated and fractional workers focus solely on GC marking
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment