为什么我们需要在调用atomic.AddUint64和其他类似的原子操作之后调用runtime.Gosched?

Going through Go by Example: Atomic Counters. The code example calls runtime.Gosched after calling atomic.AddUint64.

atomic.AddUint64 is called to

ensure that this goroutine doesn’t starve the scheduler

Unfortunately, I am finding the explanation not so meaty and satisfying.

I tried running the sample code (comments removed for conciseness):

package main

import "fmt"
import "time"
import "sync/atomic"
import "runtime"

func main() {

    var ops uint64 = 0

    for i := 0; i < 50; i++ {
        go func() {
            for {
                atomic.AddUint64(&ops, 1)

                runtime.Gosched()
            }
        }()
    }

    time.Sleep(time.Second)

    opsFinal := atomic.LoadUint64(&ops)
    fmt.Println("ops:", opsFinal)
}

without the runtime.Gosched() (go run conc.go) and the program never exited even when I reduced the loop from 50 to 1.

Question:

What happens under the hood after the call to atomic.AddUint64 that it is necessary to call runtime.Gosched? And how does runtime.Gosched fixes this? I did not find any hint to such a thing in sync/atomic's documentation.

This is how cooperative multithreading works. If one thread remains ready to run, it continues to run and other threads don't. Explicit and implicit pre-emption points are used to allow other threads to run. If your thread has a loop that it stays in for lots of time with no implicit pre-emption points, you'll starve other threads if you don't add an explicit pre-emption point.

This answer has much more information about when Go uses cooperative multithreading.