← blog
Aug 26, 2026

Multithreading OpenGL: The Shared-Context Pattern

An OpenGL context can only be current on one thread at a time. Every GL call operates on whatever context happens to be current on the thread making the call. So "multithreaded OpenGL" doesn't mean calling glDrawArrays from two threads at once. It means moving work that isn't drawing, usually asset loading, off the render thread and onto another one.

The idea

You make a second GL context that shares objects with your main one. A worker thread parses a file and uploads buffers on that second context. The main thread keeps rendering the whole time. When the worker is done, the main thread picks up the buffers and finishes setting them up.

Setup, once at startup:

// main thread, at init
HGLRC ghrc = wglCreateContext(hdc);
wglMakeCurrent(hdc, ghrc);

HGLRC ghrcLoader = wglCreateContext(hdc); // same pixel format
wglShareLists(ghrc, ghrcLoader); // must happen before ghrcLoader is ever made current

GLX and EGL have the same idea under different names. The important part is the order: share the lists before the second context is used anywhere.

What actually gets shared

Buffers and textures are shared. VAOs and FBOs are not. This trips almost everyone up the first time, so it's worth just memorizing:

Shared Not shared
Buffer objects (VBO, EBO, UBO, SSBO) Vertex array objects (VAO)
Textures Framebuffer objects (FBO)
Shader and program objects Query objects
Sync objects

Buffers and textures are just memory plus a bit of metadata, so the driver can hand them to any context in the share group. A VAO isn't data, it's a set of bindings: which buffer feeds which attribute slot. That binding state belongs to one context only. Build a VAO on the worker context and bind it from the main context, and you get undefined behavior. Sometimes it renders nothing, sometimes it works for a while and then the driver crashes later.

So the rule is simple: upload data on the worker thread, build the VAO on the thread that will actually render with it.

Worker thread

void LoadMeshAsync(const char* path) {
    wglMakeCurrent(hdc, ghrcLoader);

    MeshData mesh = ParseMeshFile(path); // pure CPU work, no GL calls here

    glGenBuffers(1, &mesh.vbo);
    glBindBuffer(GL_ARRAY_BUFFER, mesh.vbo);
    glBufferData(GL_ARRAY_BUFFER, mesh.vertexBytes, mesh.vertices, GL_STATIC_DRAW);

    glGenBuffers(1, &mesh.ebo);
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, mesh.ebo);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, mesh.indexBytes, mesh.indices, GL_STATIC_DRAW);

    glFinish(); // block until the GPU has actually finished the uploads
    wglMakeCurrent(NULL, NULL);

    PushToMainThreadQueue(mesh); // hand off vbo/ebo handles, mesh is ready to consume
}

Main thread

// after join(), or after pulling the finished mesh off the queue
GLuint vao;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);

glBindBuffer(GL_ARRAY_BUFFER, mesh.vbo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, mesh.ebo);

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)0);
glEnableVertexAttribArray(0);

mesh.vao = vao; // now safe to draw

Notice the worker never touches glGenVertexArrays. That call, and the attribute setup that follows it, only ever happens on the main thread.

The speedup here comes from overlap, not from GL calls running in parallel. While the worker parses the file (CPU-bound, no GL involved), the main thread is free to compile shaders or decode other textures. That's where the wall-clock time goes down. If you spawn a thread and immediately join() it with nothing else running in between, you've gained nothing and just paid for thread creation and a context switch.

A note on synchronization

GL calls are queued, not executed instantly. When the worker calls glBufferData, the driver may just record the command and return right away. If the main thread reads that buffer before the GPU has actually written it, you get a race.

glFinish() is the blunt fix: it stalls the calling thread until every queued GL command has actually finished on the GPU. It works, but it's heavy, since it also waits out any unrelated work still sitting in that context's queue.

A fence sync object is the lighter alternative:

GLsync fence = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
// hand the fence to the main thread instead of blocking here

// main thread, later, only when it actually needs the data
glClientWaitSync(fence, GL_SYNC_FLUSH_COMMANDS_BIT, timeoutNs);
glDeleteSync(fence);

The fence marks a point in the command stream. Instead of the worker thread stalling right away, whoever needs the result can check the fence later, right before they touch the data, and only wait as long as necessary. For a background loader this matters: the worker can keep parsing the next asset instead of sitting idle on glFinish().

Where this is worth doing

Things to watch for

What it actually saved

Loading a 94MB .glb and building its GPU buffers, single threaded (compile shader, then parse and upload, then decode texture, all one after another) versus multithreaded (parse and upload on a worker with a shared context, running at the same time as shader compile and texture decode on the main thread, VAO built on main after the worker finishes) came out roughly 30% faster wall clock. All of that gain came from overlapping the Assimp parse with work the main thread was doing anyway.