C++
threading
structs
function parameters
parallel programming

C Passing struct to a function between threads

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Passing a struct to work running on another thread is mostly a question of ownership and lifetime, not syntax. The real decision is whether the thread should receive its own copy, share immutable data, or share mutable data protected by synchronization.

Copying Is the Safest Default

If the struct is reasonably small or copying is acceptable, pass it by value to the thread function. Each thread gets its own independent object and there is no shared-state race.

cpp
1#include <iostream>
2#include <string>
3#include <thread>
4
5struct Job {
6    int id;
7    std::string payload;
8};
9
10void worker(Job job) {
11    std::cout << "job=" << job.id << " payload=" << job.payload << "\n";
12}
13
14int main() {
15    Job job{42, "build-index"};
16    std::thread t(worker, job);
17    t.join();
18}

That call copies job into the thread's argument list. The worker can modify its local copy without affecting the original.

This is usually the best starting point because it eliminates a large class of threading bugs.

Sharing a Struct by Reference

Sometimes the worker must operate on the original struct instead of a copy. In C++, that means passing a reference with std::ref.

cpp
1#include <iostream>
2#include <thread>
3#include <functional>
4
5struct Counter {
6    int value = 0;
7};
8
9void increment(Counter& counter) {
10    counter.value += 1;
11}
12
13int main() {
14    Counter counter;
15    std::thread t(increment, std::ref(counter));
16    t.join();
17    std::cout << counter.value << "\n";
18}

Without std::ref, the thread would receive a copy instead.

But once you share the original object, you also take responsibility for synchronization if more than one thread may touch it.

Shared Mutable Data Needs Synchronization

If multiple threads can read and write the same struct, you need a mutex, atomics, or another synchronization primitive. Otherwise you have a data race.

cpp
1#include <iostream>
2#include <mutex>
3#include <thread>
4
5struct Stats {
6    int processed = 0;
7};
8
9Stats stats;
10std::mutex statsMutex;
11
12void worker(int count) {
13    for (int i = 0; i < count; ++i) {
14        std::lock_guard<std::mutex> lock(statsMutex);
15        stats.processed += 1;
16    }
17}
18
19int main() {
20    std::thread a(worker, 1000);
21    std::thread b(worker, 1000);
22    a.join();
23    b.join();
24
25    std::cout << stats.processed << "\n";
26}

The lock is not optional here. Without it, incrementing processed from multiple threads is undefined behavior.

Lifetime Is the Most Common Hidden Bug

A thread must never outlive the data it uses. This is where many examples go wrong. Consider this bad pattern:

cpp
1std::thread startThread() {
2    Job local{7, "temp"};
3    return std::thread(worker, std::ref(local));
4}

local is destroyed when startThread returns. The new thread now holds a dangling reference.

If the worker needs shared access beyond the caller's stack frame, use one of these instead:

  • copy the struct into the thread
  • allocate shared lifetime explicitly with std::shared_ptr
  • keep the owner alive until the thread completes

std::shared_ptr for Shared Ownership

When several threads legitimately need access to the same heap object, std::shared_ptr can make lifetime management clearer.

cpp
1#include <iostream>
2#include <memory>
3#include <thread>
4
5struct Config {
6    int retries;
7};
8
9void worker(std::shared_ptr<Config> config) {
10    std::cout << config->retries << "\n";
11}
12
13int main() {
14    auto config = std::make_shared<Config>();
15    config->retries = 3;
16
17    std::thread t(worker, config);
18    t.join();
19}

This solves lifetime, but it does not solve shared mutable access automatically. If the object is mutable and shared, you still need synchronization.

POSIX Threads Use the Same Rules

If you are writing C with pthread_create, the mechanics look different, but the rules are identical: the pointed-to struct must remain alive, and shared mutation needs synchronization.

c
1#include <pthread.h>
2#include <stdio.h>
3
4typedef struct {
5    int value;
6} Context;
7
8void* run(void* arg) {
9    Context* ctx = (Context*)arg;
10    printf("value=%d\n", ctx->value);
11    return NULL;
12}
13
14int main() {
15    pthread_t tid;
16    Context ctx = { .value = 10 };
17    pthread_create(&tid, NULL, run, &ctx);
18    pthread_join(tid, NULL);
19    return 0;
20}

The syntax changes, but the ownership and race concerns do not.

Common Pitfalls

A common mistake is passing a reference or pointer to a local struct that goes out of scope before the thread finishes.

Another mistake is assuming that because the code compiled, sharing mutable fields is safe. It is not safe without synchronization.

Developers also sometimes optimize too early by avoiding copies when a simple value copy would have been the safest design.

Finally, std::shared_ptr solves lifetime only. It does not make concurrent mutation safe by itself.

Summary

  • Copy the struct into the thread when possible. It is the safest default.
  • Use std::ref only when the thread must work on the original object.
  • Protect shared mutable structs with a mutex or another synchronization mechanism.
  • Make sure any referenced data outlives the thread that uses it.
  • Separate lifetime management from synchronization in your design. They are related, but they solve different problems.

Course illustration
Course illustration

All Rights Reserved.