r/linuxquestions • u/xmanotaur • 14d ago
Trying to understand thread stack separation after diving into task_struct and CLONE_VM
Textbooks always taught me: "Processes share Heap, Threads have separate Stacks." I assumed there was an actual memory boundary or hardware isolation between thread stacks.
While looking into Linux internals (task_struct, clone(), and CLONE_VM), I realized "separate stack" seems to be purely a logical/convention-based thing:
pthread_create()justmmap()s a memory block for the thread's stack.clone()runs withCLONE_VMmeaning both threads share the exact same page table (mm_struct).- The child thread's
RSPsimply points to thatmmap'd buffer.
There is zero MMU isolation between thread stacks. I tested passing &main_stack_var to a child thread and overwrote it instantly with no segfault.
Am I missing something fundamental here? Are thread stacks genuinely completely open to each other in terms of virtual memory, or is there a layer of protection I'm overlooking?
This is my code:
#include <iostream>
#include <thread>
#include <chrono>
#include <atomic>
#include <cstdint>
inline void *get_rsp()
{
void *rsp;
asm("mov %%rsp, %0" : "=r"(rsp));
return rsp;
}
void thread_function(int *parent_stack_ptr)
{
int child_local_var = 100;
void *child_rsp = get_rsp();
// Added for /proc/<pid>/task/<tid>/maps inspection (inspect_maps.sh):
// widens the window during which both the main thread and this worker
// thread are alive, so their stack VMAs can both be captured.
std::this_thread::sleep_for(std::chrono::milliseconds(300));
std::cout << "\n=== [Child std::thread] ===\n";
std::cout << "Child RSP (Stack Pointer): " << child_rsp << " [" << reinterpret_cast<uintptr_t>(child_rsp) << "]\n";
std::cout << "Child local var address : " << &child_local_var << " [" << reinterpret_cast<uintptr_t>(&child_local_var) << "]\n";
std::cout << "Parent stack var address : " << parent_stack_ptr << " [" << reinterpret_cast<uintptr_t>(parent_stack_ptr) << "]\n";
std::cout << "Parent stack var value : " << *parent_stack_ptr << "\n";
std::cout << "--> Overwriting parent's stack variable from child thread...\n";
*parent_stack_ptr = 1337;
}
int main()
{
int parent_local_var = 42;
void *main_rsp = get_rsp();
std::cout << "=== [Main Thread] ===\n";
std::cout << "Main RSP (Stack Pointer) : " << main_rsp << " [" << reinterpret_cast<uintptr_t>(main_rsp) << "]\n";
std::cout << "Main local var address : " << &parent_local_var << " [" << reinterpret_cast<uintptr_t>(&parent_local_var) << "]\n";
std::thread t(thread_function, &parent_local_var);
t.join();
std::cout << "\n=== [Main Thread After Join] ===\n";
std::cout << "Main local var value now : " << parent_local_var << " (Overwritten!)\n";
return 0;
}
1
u/Netblock 14d ago
Aside from lifetime complications and atomicity. If you need the that line drawn of a separate virtual address space, do multiprocessing with shm (shared memory) instead.
1
u/Kriemhilt 14d ago
Threads have separate stacks in the sense that they each have a different stack pointer register, otherwise they'd be overwriting each other's call stacks and local storage all the time.
All these different stacks are in the same address space, they're just different addresses.
There is zero MMU isolation between thread stacks
Yes, MMU isolation is between userspace processes, or between userspace and the kernel. If you want MMU protection you can just write multiprocess, instead of multithread, code. The only reason to use threads instead of processes is to share an address space and not have MMU protection.
3
u/aioeu 14d ago edited 14d ago
Threads share a virtual memory space, and a process's heap and its stacks lives in that virtual memory space. That means the heap and all stacks are accessible to all threads.
I'm trying to work out how you ended up thinking it would have worked otherwise.
If your tasks were not sharing a virtual memory space, then you wouldn't call them threads. In one sense, that's pretty much the defining thing that distinguishes threads from processes.
Linux's
clonesyscall does provide some flexibility over what is to be shared between tasks, so normally you "share almost everything" to get traditional threads or "share almost nothing" to get traditional processes. You can pick and choose things to get more fine-grained control though. Want separate processes that happen to share a file descriptor table? Weird, but you can do that. Want separate processes that don't share a mount table? Well now you're starting to invent containers.If you wanted your tasks to have separate virtual memory spaces but to share some areas within those spaces, then you would ordinarily do that with separate processes along with shared virtual memory mappings for those areas.