Go: Under the Hood Under the Hood
中文

19.2 Graphics Bindings and Thread Affinity

19.1 placed Go in the application stage of the pipeline, where it issues draw calls. Before the first draw call, a Go program must satisfy a constraint unrelated to graphics itself: some graphics APIs and window systems tie state to operating-system threads, while goroutines migrate between threads. The constraint has two layers. The first comes from OpenGL: a context is “current” per thread. The second comes from the platform: windows and events must be handled on the process’s main thread. This section discusses the two layers in turn and how runtime.LockOSThread satisfies them (the mechanism and cost of locking a thread are covered in 9.5, and its use in device programming in 18.2).

19.2.1 The OpenGL Context: A State Machine That Is Current per Thread

OpenGL is organized around the context. A context is a large implicit state machine holding the currently bound shaders, textures, buffers, blend mode, viewport and other state. Most OpenGL functions take no context parameter and act on the “current context”: glBindTexture modifies the texture binding in the current context, and glDrawElements uses all the state of the current context.

“Current” is defined per thread. The OpenGL Wiki puts it this way: OpenGL commands need a current context to take effect, and all commands affect the current context; the current context is a thread-local variable, so multiple threads in one process may each have their own current context, but a single context cannot be current in more than one thread at a time [Khronos, n.d.a]. The context interfaces on every platform follow this rule: EGL’s eglMakeCurrent binds a context to the calling thread [Khronos, n.d.b]; GLFW’s glfwMakeContextCurrent makes a context current on the calling thread and requires that, before the context is moved to another thread, it first be made non-current on the original thread [GLFW, n.d.].

A sequence of OpenGL calls must therefore be issued on the thread that holds the current context. If some of them land on another thread with no current context, those calls lose the state they depend on, and OpenGL does not guarantee the result.

Thread affinity is not common to all graphics APIs. Vulkan, discussed in 19.1.3, has no “current context”: objects are passed explicitly to each command as handles, and the specification only requires external synchronization for some parameters, without confining calls to one thread. The first constraint of this section applies to APIs such as OpenGL that organize state in thread-local variables; the second constraint holds for every graphics API, because it comes from the window system.

19.2.2 Goroutines Migrate, So Lock the Thread

The Go scheduler does not bind a goroutine to a thread. After a goroutine yields because of a system call, a channel operation or preemption, it will likely continue on a different M (Chapter 9). For pure Go code this migration is invisible. For OpenGL it means that between two calls the goroutine may have moved to a thread with no current context.

runtime.LockOSThread is designed for such cases. Its documentation says that the calling goroutine is wired to its current thread and that no other goroutine will run on that thread; a goroutine should call it before calling operating-system services or non-Go library functions that depend on per-thread state [Go Authors, 2026]. Rendering code is therefore usually organized as a dedicated goroutine that first locks the thread, then makes the context current on that thread, and thereafter issues all OpenGL calls. The following sketch uses the go-gl/glfw API (Scene and draw stand for the application’s own type and drawing function):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// renderLoop exclusively owns the OpenGL context of win.
func renderLoop(win *glfw.Window, frames <-chan Scene) {
	runtime.LockOSThread() // from now on this goroutine runs only on the current thread
	defer runtime.UnlockOSThread()

	win.MakeContextCurrent() // the context becomes current on this thread
	defer glfw.DetachCurrentContext()

	for scene := range frames {
		draw(scene)       // all OpenGL calls are issued on the same thread
		win.SwapBuffers() // GLFW allows this to be called from any thread
	}
}

Other goroutines do not call OpenGL directly; they hand each frame’s scene to this goroutine over a channel. This is the same structure as the arrangement in 18.2, where one thread-locked goroutine exclusively owns the device: whenever external state is bound to a thread, a thread-locked goroutine is its sole owner and the other goroutines use it by communicating.

Locking has a cost. 9.5 points out that a locked M is dedicated to one goroutine, and every time that goroutine blocks and wakes, the P must be handed off between threads. The rendering goroutine should therefore receive work in units of whole frames and avoid blocking frequently on small operations.

19.2.3 The Main Thread

Some platforms are stricter: windows and events must be handled by the process’s main thread, the thread that executes main when the process starts. The GLFW documentation states the scope of this restriction:

1
2
3
Initialization, termination, event processing and the creation and
destruction of windows, cursors and OpenGL and OpenGL ES contexts are all
restricted to the main thread due to limitations of one or several platforms.

macOS is the representative platform. Apple’s Threading Programming Guide states that the application’s main thread handles events, that it is the thread blocked in the run method of NSApplication, and that operations such as creating, destroying, resizing and moving an NSView should be performed only on the main thread [Apple, n.d.].

A Go program’s main function does not satisfy this requirement by default. The runtime’s runtime.main starts on the main thread (m0), locks the main goroutine to that thread during initialization, and unlocks it when initialization finishes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func main() {
	mp := getg().m
	// ...
	// Lock the main goroutine onto this, the main OS thread,
	// during initialization. Most programs won't care, but a few
	// do require certain calls to be made by the main thread.
	// Those can arrange for main.main to run in the main thread
	// by calling runtime.LockOSThread during initialization
	// to preserve the lock.
	lockOSThread()

	if mp != &m0 {
		throw("runtime.main not on m0")
	}
	// ...
	for m := &firstmoduledata; true; m = m.next {
		doInit(m.inittasks)
		if m == last {
			break
		}
	}
	// ...
	close(main_init_done)

	needUnlock = false
	unlockOSThread()
	// ...
}

All init functions run inside doInit and therefore on the main thread. The runtime’s internal lockOSThread and the user’s LockOSThread are counted separately, and the lock is released only when both reach zero (9.5). If an init function calls runtime.LockOSThread, the runtime releases only its own layer at the end of initialization, the user’s lock stays in effect, and main.main also runs on the main thread. The documentation of LockOSThread spells out this usage [Go Authors, 2026], and the example program of go-gl/glfw begins with it [go-gl, n.d.]. Combined with the rendering goroutine of the previous subsection, the skeleton of a program looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func init() {
	// init runs on the main thread; locking here makes main.main run on it too
	runtime.LockOSThread()
}

func main() {
	if err := glfw.Init(); err != nil { // initialization: main thread only
		log.Fatal(err)
	}
	defer glfw.Terminate()

	win, err := glfw.CreateWindow(640, 480, "demo", nil, nil) // create window and context: main thread only
	if err != nil {
		log.Fatal(err)
	}

	frames := make(chan Scene)
	go renderLoop(win, frames) // rendering: locked to another thread
	go simulate(frames)        // application logic: an ordinary goroutine

	for !win.ShouldClose() {
		glfw.WaitEvents() // event processing: main thread only
	}
}

The division of labor here is the opposite of the habit in many other languages. The main thread does not run application logic; it is given to the window system’s event loop. Application logic runs in ordinary goroutines and sends an operation over when it needs the main thread. Go GUI and game frameworks make this arrangement on the user’s behalf, at the price of requiring the user to call a blocking entry function from main. Ebitengine’s RunGame must be called on the main thread; Ebitengine uses runtime.LockOSThread to bind the main goroutine to the main thread and provides RunOnMainThread so that other goroutines can run functions there [Ebitengine, n.d.]. Gio’s app.Main must be called at the very end of main, because some operating systems require it to take control of the main thread to run the window [Gio, n.d.]. Fyne’s App.Run should be called at the end of main and blocks; since Fyne 2.6 it also provides fyne.Do and fyne.DoAndWait, which let background goroutines modify the UI in Fyne’s main run context [Fyne, n.d.].

Programs that do not use these frameworks can use golang.design/x/mainthread [Ou, 2020]. It distills the arrangement above into a small package: the package’s init calls runtime.LockOSThread, and Init puts the user’s main function into a new goroutine while looping on the main thread itself, executing the functions that other goroutines send through Call (which waits for completion) or Go (which does not):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func init() {
	runtime.LockOSThread()
}
// ...
func Init(main func()) {
	done := donePool.Get().(chan any)
	defer donePool.Put(done)

	go func() {
		defer func() {
			done <- nil
		}()
		main()
	}()

	for {
		select {
		case f := <-funcQ:
			dispatch(f)
		case <-done:
			return
		}
	}
}

Call sends the function and a completion channel into funcQ and then waits on the completion channel; if the function panics on the main thread, the panic value is sent back and re-raised in the caller’s goroutine. The package has since been merged into golang.design/x/runtime and is maintained there as the mainthread subpackage.

macOS adds a further restriction: if a program needs the Cocoa event loop, the main thread must enter [NSApp run] and cannot at the same time sit in a Go select loop. For this reason golang.design/x/hotkey carries a darwin-specific variant of mainthread [Ou, 2021]. Its Init likewise puts the user function into a new goroutine, while the main thread calls [NSApp run] through cgo and hands itself to Cocoa. When another goroutine calls Call, the function is first placed in a Go channel, a dispatch is then scheduled on the main queue with GCD’s dispatch_async, and Cocoa’s event loop calls back into Go, which takes the function out and runs it on the main thread. In this layout the main thread belongs to the platform’s event loop and Go code can enter it only as a callback. The hotkey documentation also notes that applications based on Fyne, Ebitengine or Gio do not need this package, because those frameworks already run the event loop on the main thread.

19.2.4 Bindings and Thread Discipline

Exposing C APIs such as OpenGL and Vulkan to Go is routine FFI work technically. The go-gl family of projects wraps GL functions one by one with cgo (15.6). purego calls C functions without cgo; it originated in Ebitengine, which first achieved Go-only builds on Windows, so that GOOS=windows could be set to cross-compile from any system, and the purego project aims to bring this to other platforms [purego, n.d.]. Ebitengine v2.4 (August 2022) announced “truly pure Go” on Windows [Hoshi, 2022]; since v2.10 (September 2026), builds on macOS, Linux and BSD no longer need cgo either, so every desktop platform can be built with Go alone [Hoshi, 2026]. The GPU layer of polyred likewise reaches Metal, EGL/GLES and Vulkan through purego and can therefore be built with CGO_ENABLED=0 [Ou, n.d.].

Whichever approach is chosen, the mechanism of the binding layer is the FFI boundary discussed in Chapter 15 and 18.1, and functions can be taken over one to one. What decides whether the program runs correctly is the thread discipline discussed in this section: on which thread the context is current, whether the goroutine may migrate, and which calls must happen on the main thread. These constraints do not appear in function signatures, yet violating them can produce a black screen or a crash. To evaluate a Go graphics library, then, look not only at how many APIs it covers but at how it arranges LockOSThread and the main thread.

Summary

An OpenGL context is an implicit state machine that is effective per thread, while goroutines migrate between threads. The remedy is to lock the rendering goroutine to one thread with runtime.LockOSThread, so that it owns the context exclusively and other goroutines communicate with it over channels, the same structure as the thread-locked goroutine that owns the device in Chapter 18. Explicit APIs such as Vulkan have no thread-local current context and require only external synchronization. The window-system constraint is more general: GLFW confines initialization, event processing and window creation to the main thread, and macOS requires the main thread to handle events and views. A Go program uses LockOSThread in init to keep main.main on the main thread, gives the main thread to the event loop, and moves application logic into ordinary goroutines; Ebitengine, Gio, Fyne and the mainthread package each encapsulate this arrangement in their own way. Whether the graphics API is bound through cgo or purego, the mechanism is the same FFI boundary, and the part that needs real care is the threads.

Handing rendering to the GPU means accepting the constraints of this boundary. The next section discusses another route: the software rendering of 19.3 computes pixels entirely on the CPU, gets its parallelism from goroutines and SIMD, and no longer goes through a graphics driver.

Further Reading