C / MULTI-FILE PROGRAMS AND BUILDS
Storage classes: auto, register and thread-local
Pick storage classes for locals and per-thread state: why auto is a no-op, what register enforces, and how _Thread_local links across translation units.
What you will learn
- Spell auto only for emphasis: every block-scope object already has automatic storage
- Use register to make taking an object's address a compile error, not a speed hint
- Give each thread its own counter with _Thread_local instead of locking a static one
- Repeat _Thread_local in the header's extern declaration and build with gcc -pthread
Understanding Storage classes: auto, register and thread-local
A storage class specifier answers how long an object lives, not where the hardware keeps it. auto and register both mean automatic storage duration: the object comes into existence when control enters the block and is gone when control leaves, which is already what a declaration inside a function gives you, so writing auto changes nothing. That is why auto survives mainly as documentation, and why it is a constraint violation at file scope, where objects have static storage duration by definition.
register asks for the same lifetime but attaches a prohibition: the object's address may not be taken, so &x, scanf("%d", &x) and handing x to a function that wants a pointer are all constraint violations the compiler must reject. The prohibition, not the speed hint, is what the standard actually guarantees; gcc and clang choose registers from liveness analysis and ignore the keyword entirely. Read register as a compile-time promise that the object never escapes, which is also why it is the one storage class a function parameter is allowed to have.
_Thread_local adds a third storage duration next to automatic and static. Such an object exists once per thread, is created when the thread starts, is initialized from a constant expression before your thread function runs a single statement, and dies with the thread, so its address differs from thread to thread. At block scope it must be paired with static or extern, and every declaration of the same object has to repeat _Thread_local, which is why a shared header carries extern _Thread_local int counter; while exactly one translation unit defines it.
<stdio.h>
<pthread.h>
static int shared_total = 0; /* one object for the whole program */
static _Thread_local int per_thread = 0; /* one object per thread */
static void *tally(void *name)
{
for (register int i = 1; i <= 3; i++) {
auto int step = i; /* auto: the block-scope default, spelled out */
per_thread += step;
shared_total += step;
}
printf("%s: per_thread=%d shared_total=%d\n",
(const char *)name, per_thread, shared_total);
return NULL;
}
int main(void)
{
pthread_t t;
pthread_create(&t, NULL, tally, "worker A");
pthread_join(t, NULL);
pthread_create(&t, NULL, tally, "worker B");
pthread_join(t, NULL);
tally("main");
return 0;
}
These specifiers choose an object's storage duration rather than its performance: auto is the block-scope default, register is that default plus a compiler-enforced ban on taking the address, and _Thread_local gives every thread a private copy.
Worked examples
register where it is legal
Uses register on locals and on parameters, the only place a parameter may carry a storage class.
<stdio.h>
static long dot(register const int *a, register const int *b, register int n)
{
register long acc = 0;
while (n-- > 0)
acc += (long)*a++ * *b++;
return acc;
}
int main(void)
{
int x[4] = {1, 2, 3, 4};
int y[4] = {5, 6, 7, 8};
printf("dot = %ld\n", dot(x, y, 4));
return 0;
}
Example explained
Line 1register const int *a makes the pointer itself register; a parameter is allowed no other storage class.
Line 2acc += (long)*a++ * *b++; walks both arrays and never needs &a, &b or &acc, so the promise holds.
Line 3Adding int *p = &acc; inside dot turns a working file into a compile error; that diagnostic is the only guaranteed effect of the keyword.
Line 4register int tmp[4]; would compile, but indexing it is undefined behaviour because subscripting needs the array-to-pointer conversion.
One object per thread, proved by address
Shows that each thread gets a separate _Thread_local object with its own initial value and its own address.
<stdio.h>
<pthread.h>
static _Thread_local int slot = 100; /* one copy per thread, each starting at 100 */
static int *main_copy;
static void *probe(void *label)
{
slot += 5;
printf("%s slot=%d is_main_copy=%s\n",
(const char *)label, slot, (&slot == main_copy) ? "yes" : "no");
return NULL;
}
int main(void)
{
pthread_t t;
main_copy = &slot; /* the main thread's copy lasts for the whole run */
pthread_create(&t, NULL, probe, "A");
pthread_join(t, NULL);
pthread_create(&t, NULL, probe, "B");
pthread_join(t, NULL);
probe("main");
return 0;
}
Example explained
Line 1slot = 100 is a constant initializer, so thread B starts at 100 instead of inheriting the 105 thread A left behind.
Line 2main_copy is captured before any thread exists and stays valid, because the main thread's copy lives as long as the program.
Line 3&slot == main_copy is false inside both workers: the name resolves to a different object per thread, not to different values of one object.
Line 4Each thread is joined before the next is created, so the printf calls cannot interleave and the order is fixed.
Important notes
gcc and clang treat register purely as a constraint and ignore it when allocating registers, so sprinkling it over hot loops changes nothing measurable; GCC's register int x asm("r12") is a separate non-standard extension.
In C11 and C17 you can write thread_local after including <threads.h>; C23 makes thread_local a keyword and gives bare auto a second job, type inference, so auto x = 1; there deduces int instead of restating the old default.
Common mistakes
Taking the address of a register object: &i or scanf("%d", &i) fails to compile with 'address of register variable requested', and register int buf[8]; compiles but indexing buf is undefined behaviour.
Initializing a thread-local from a runtime value, as in _Thread_local int id = next_id();, is rejected with 'initializer element is not constant', because the copy must be set up before the thread runs any code; assign it at the top of the thread function instead.
Writing extern int counter; in the header while the definition says _Thread_local int counter;: the declarations disagree, and you get a link error about a TLS reference mismatching a non-TLS reference, or code that quietly reads the wrong object.
Try it yourself
Change, predict, then run
Change per_thread in the main example to start at 100 and call tally twice inside each worker, predicting both printed numbers before you run it. Then add a line that prints &i for the register loop variable and confirm the file no longer compiles.
Open the C workspaceCheck your understanding
A function declares register int n = 100; and loops over it, and you build with gcc -O2. What actually changes if you delete the word register?
- Nothing in the generated code, but &n becomes legal
- n moves from a CPU register onto the stack, so the loop runs slower
- n gains static storage duration and keeps its value between calls
- n becomes visible to other translation units via extern
Show answer
gcc and clang allocate registers from liveness analysis, not from the keyword, so the machine code is the same either way; the only thing the standard ties to register is the constraint that the object's address may not be taken, and removing it lifts that ban. Option 1 is the intuitive reading of the name, but at -O2 placement is the allocator's decision, and a variable whose address is never taken usually ends up in a register regardless.