<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Ernesto Enriquez</title>
    <description>The latest articles on Forem by Ernesto Enriquez (@ernesto905).</description>
    <link>https://forem.com/ernesto905</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3820288%2F8589ffa5-726f-4653-a7f2-db3fd0bbf476.png</url>
      <title>Forem: Ernesto Enriquez</title>
      <link>https://forem.com/ernesto905</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/ernesto905"/>
    <language>en</language>
    <item>
      <title>Lifecycle of a process</title>
      <dc:creator>Ernesto Enriquez</dc:creator>
      <pubDate>Wed, 18 Mar 2026 14:34:37 +0000</pubDate>
      <link>https://forem.com/ernesto905/lifecycle-of-a-process-1amm</link>
      <guid>https://forem.com/ernesto905/lifecycle-of-a-process-1amm</guid>
      <description>&lt;p&gt;With Linux version 7 just around the corner, I thought it would be interesting to trace process creation in as much painstaking detail as one weekend and 6 cups of coffee would allow. &lt;/p&gt;

&lt;p&gt;Namely, I want to answer the question of what &lt;em&gt;exactly&lt;/em&gt; happens when we open a new browser tab or start our favorite video game. I’ll be focusing on processes spawned by other processes&lt;sup id="fnref1"&gt;1&lt;/sup&gt; through libc, since the C standard library is about as close as we're going to get to the bedrock of the very universe. If you use CPython, this mechanism applies as well. &lt;/p&gt;

&lt;p&gt;We'll be taking the following journey together: &lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2F2026%2FLife%2520of%2520a%2520process%2Fdiagrame.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2F2026%2FLife%2520of%2520a%2520process%2Fdiagrame.png" width="639" height="632"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I like to think of this as expanding on &lt;a href="https://thorstenball.com/blog/2014/06/13/where-did-fork-go/" rel="noopener noreferrer"&gt;Thorsten Ball’s “Where did fork go?”&lt;/a&gt;. In his post, Thorsten gives insight into what happens when you fork a process. It’s a great read, you should check it out. &lt;/p&gt;

&lt;p&gt;The mechanism I’ll be describing also includes processes started by a programmer. Indeed, this is what most people &lt;em&gt;see&lt;/em&gt; when they use a computer to create some application. However, under the hood there is always a process required to start another process, such as a shell (e.g, &lt;code&gt;./&amp;lt;process&amp;gt;&lt;/code&gt; on bash) or an init system (e.g, creating a service with Systemd).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2F2026%2FLife%2520of%2520a%2520process%2Fastronaut-process.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2F2026%2FLife%2520of%2520a%2520process%2Fastronaut-process.png" width="800" height="449"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Simple.c
&lt;/h2&gt;

&lt;p&gt;Consider a C program whose only purpose in life is to spawn a child process (id est, &lt;a href="https://www.youtube.com/watch?v=3ht-ZyJOV2k" rel="noopener noreferrer"&gt;it passes butter&lt;/a&gt;). &lt;br&gt;
Why so simple? I figured this program would impose the least cognitive load. I do this for your understanding. I don’t think you’re stupid. I think you’re smart, and beautiful, and precious, and worth letting merge into my lane during rush hour.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simple.c&lt;/span&gt;
&lt;span class="cp"&gt;#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;unistd.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;fork&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before continuing, I recommend reading the &lt;a href="https://www.man7.org/linux/man-pages/man2/fork.2.html" rel="noopener noreferrer"&gt;fork man page&lt;/a&gt;, at least up to the errors section. It’s a quite short read, and If I recall correctly, there was a Harvard health study that claimed that programmers that read linux man pages are 78% less likely to develop carpal tunnel [//TODO: citation needed]&lt;/p&gt;

&lt;p&gt;The tldr is that fork() is a function in libc&lt;sup id="fnref2"&gt;2&lt;/sup&gt; that spawns a child process from the parent process that called it. This child is &lt;em&gt;almost&lt;/em&gt; identical to the parent. &lt;br&gt;
Like the parent, the child will capture the return value from the fork() and execute any code after fork(). &lt;/p&gt;

&lt;p&gt;Unlike the parent, however, the child will not execute fork() or any code before it. Moreover, the child’s fork() will return a different value, the child will have a different process ID than the parent, and a couple other things you’d know if you read the manual. &lt;/p&gt;

&lt;p&gt;Fork is a system call (not to be confused with fork(), the libc function). System calls are services the linux kernel provides to processes in user space. You can also think of system calls as an API provided by the Kernel. The API &lt;em&gt;contract&lt;/em&gt; in this sense includes things like what values are expected in what registers and what assembly instruction (e.g, &lt;code&gt;int 0x80&lt;/code&gt;, &lt;code&gt;syscall&lt;/code&gt;, etc.) to execute. The Standard C Library (libc) also provides an API that simplifies working with system calls, in the form of functions that wrap the Kernel’s system call API.&lt;/p&gt;

&lt;p&gt;You could in theory provision these system call services directly from the Kernel, bypassing libc completely. If you’re particularly keen on managing thread safety, thinking about register values, and programming in assembly then please, by all means, knock yourself out. &lt;/p&gt;

&lt;p&gt;Now here’s the kicker: fork(), the libc function, does &lt;em&gt;not&lt;/em&gt; call Fork, the system call. Rather, fork() &lt;em&gt;eventually&lt;/em&gt; calls Clone, yet another system call. &lt;/p&gt;

&lt;p&gt;Why is this? &lt;/p&gt;

&lt;p&gt;Let’s dive into fork() and see what we find. &lt;/p&gt;
&lt;h2&gt;
  
  
  A journey of a thousand miles
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// unistd.h&lt;/span&gt;
&lt;span class="cm"&gt;/* Clone the calling process, creating an exact copy.
   Return -1 for errors, 0 to the new process,
   and the process ID of the new process to the old process.  */&lt;/span&gt;
&lt;span class="k"&gt;extern&lt;/span&gt; &lt;span class="n"&gt;__pid_t&lt;/span&gt; &lt;span class="n"&gt;fork&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;__THROWNL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This is the prototype for the fork() function. If you’re on linux (congrats!) the location of this file is typically found in &lt;/p&gt;

&lt;p&gt;&lt;code&gt;/usr/include/unistd.h&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The implementation of this peculiar little function takes up just over 140 lines of code at the time of this writing. You won't find said implementation in your file system. By the time you use it, it’s already a shared object file, ready to be linked to your lovely programs at runtime. This object file in question is libc.so, found in /usr/lib/.&lt;/p&gt;

&lt;p&gt;Check out the &lt;a href="https://github.com/kraj/glibc/blob/9da7ad6d74700811c9b4c82b5f5eb555e39241a7/posix/fork.c" rel="noopener noreferrer"&gt;fork.c file&lt;/a&gt; in Posix standard library in glibc if you want to take a look at the implementation. &lt;/p&gt;

&lt;p&gt;The first thing you might notice is that fork.c does not implement: &lt;/p&gt;

&lt;p&gt;&lt;code&gt;__pid_t fork (void)&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/kraj/glibc/blob/9da7ad6d74700811c9b4c82b5f5eb555e39241a7/posix/fork.c#L142" rel="noopener noreferrer"&gt;Towards the bottom of the file&lt;/a&gt; you’ll find find:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;weak_alias (__libc_fork, fork)&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Okay, so calling the fork() function in your code actually executes:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pid_t __libc_fork (void)&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;You’ll also notice inside the __libc_fork function that &lt;a href="https://github.com/kraj/glibc/blob/9da7ad6d74700811c9b4c82b5f5eb555e39241a7/posix/fork.c#L75" rel="noopener noreferrer"&gt;line 75&lt;/a&gt; makes a call to _Fork(). &lt;/p&gt;

&lt;p&gt;&lt;code&gt;pid_t pid = _Fork ();&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;So fork() in your code actually runs __libc_fork(), whose job is (among other things) to handle threading and eventually call _Fork(). &lt;/p&gt;

&lt;p&gt;Funny enough, _Fork() actually exists in a few places within the standard library. During buildtime, the _Fork() you execute is decided by your machine’s OS and CPU&lt;sup id="fnref3"&gt;3&lt;/sup&gt;. &lt;/p&gt;

&lt;p&gt;We’ll be focusing on the &lt;a href="https://github.com/kraj/glibc/blob/9da7ad6d74700811c9b4c82b5f5eb555e39241a7/sysdeps/nptl/_Fork.c#L25" rel="noopener noreferrer"&gt;_Fork.c file within sysdeps/nptl&lt;/a&gt;. Here, we see a call is made to: &lt;/p&gt;

&lt;p&gt;&lt;code&gt;pid_t pid = arch_fork (&amp;amp;THREAD_SELF-&amp;gt;tid);&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;arch_fork() is defined in its own &lt;a href="https://github.com/kraj/glibc/blob/master/sysdeps/unix/sysv/linux/arch-fork.h#L35" rel="noopener noreferrer"&gt;header file&lt;/a&gt;, and here is where we start to see how the sausage is made.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* Call the clone syscall with fork semantic.  The CTID address is used
   to store the child thread ID at its location, to erase it in child memory
   when the child exits, and do a wakeup on the futex at that address.

   The architecture with non-default kernel abi semantic should correctly
   override it with one of the supported calling convention (check generic
   kernel-features.h for the clone abi variants).  */&lt;/span&gt;
&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kr"&gt;inline&lt;/span&gt; &lt;span class="n"&gt;pid_t&lt;/span&gt;
&lt;span class="nf"&gt;arch_fork&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ctid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CLONE_CHILD_SETTID&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;CLONE_CHILD_CLEARTID&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;SIGCHLD&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;ret&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="cp"&gt;#ifdef __ASSUME_CLONE_BACKWARDS
# ifdef INLINE_CLONE_SYSCALL
&lt;/span&gt;  &lt;span class="n"&gt;ret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;INLINE_CLONE_SYSCALL&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="cp"&gt;# else
&lt;/span&gt;  &lt;span class="n"&gt;ret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;INLINE_SYSCALL_CALL&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="cp"&gt;# endif
#elif defined(__ASSUME_CLONE_BACKWARDS2)
&lt;/span&gt;  &lt;span class="n"&gt;ret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;INLINE_SYSCALL_CALL&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="cp"&gt;#elif defined(__ASSUME_CLONE_BACKWARDS3)
&lt;/span&gt;  &lt;span class="n"&gt;ret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;INLINE_SYSCALL_CALL&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="cp"&gt;#elif defined(__ASSUME_CLONE_DEFAULT)
&lt;/span&gt;  &lt;span class="n"&gt;ret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;INLINE_SYSCALL_CALL&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="cp"&gt;#else
# error "Undefined clone variant"
#endif
&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ret&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remember what I was saying about fork() calling Clone? Well, behold! &lt;/p&gt;

&lt;p&gt;Clone is used since it’s a more versatile system call. &lt;a href="https://unix.stackexchange.com/a/199695" rel="noopener noreferrer"&gt;You can read more about their differences here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;At this point it seems what lies before us is a maze of macros. I encourage you to try making your way through, starting at the &lt;a href="https://elixir.bootlin.com/glibc/glibc-2.43.9000/source/sysdeps/unix/sysv/linux/arch-fork.h#L35" rel="noopener noreferrer"&gt;arch_fork function&lt;/a&gt;. See if you can find your way to the assembly. If we keep following this thread, starting at &lt;code&gt;INLINE_SYSCALL_CALL&lt;/code&gt;, you’ll eventually arrive at the &lt;code&gt;internal_syscall0&lt;/code&gt;, &lt;code&gt;internal_syscall1&lt;/code&gt;, …, all the way up to &lt;code&gt;internal_syscall6&lt;/code&gt;.  &lt;/p&gt;

&lt;p&gt;The clone system call has 5 arguments, so we’re dealing with &lt;a href="https://elixir.bootlin.com/glibc/glibc-2.43.9000/source/sysdeps/unix/sysv/linux/x86_64/sysdep.h#L322" rel="noopener noreferrer"&gt;this guy&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cp"&gt;#undef internal_syscall5
#define internal_syscall5(number, arg1, arg2, arg3, arg4, arg5) \
({                                  \
    unsigned long int resultvar;                    \
    TYPEFY (arg5, __arg5) = ARGIFY (arg5);              \
    TYPEFY (arg4, __arg4) = ARGIFY (arg4);              \
    TYPEFY (arg3, __arg3) = ARGIFY (arg3);              \
    TYPEFY (arg2, __arg2) = ARGIFY (arg2);              \
    TYPEFY (arg1, __arg1) = ARGIFY (arg1);              \
    register TYPEFY (arg5, _a5) asm ("r8") = __arg5;            \
    register TYPEFY (arg4, _a4) asm ("r10") = __arg4;           \
    register TYPEFY (arg3, _a3) asm ("rdx") = __arg3;           \
    register TYPEFY (arg2, _a2) asm ("rsi") = __arg2;           \
    register TYPEFY (arg1, _a1) asm ("rdi") = __arg1;           \
    asm volatile (                          \
    "syscall\n\t"                           \
    : "=a" (resultvar)                          \
    : "0" (number), "r" (_a1), "r" (_a2), "r" (_a3), "r" (_a4),     \
      "r" (_a5)                             \
    : "memory", REGISTERS_CLOBBERED_BY_SYSCALL);            \
    (long int) resultvar;                       \
})
&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There you have it. We’re loading up our registers and executing the syscall instruction directly in assembly. &lt;/p&gt;

&lt;p&gt;What a ride, huh? &lt;/p&gt;

&lt;p&gt;I think that’s enough digging through our boy Stallman et al’s magnum opus. It’s a wonderful repository, to say the least, and I implore you to spend some time &lt;a href="https://sourceware.org/glibc/sources.html" rel="noopener noreferrer"&gt;exploring it&lt;/a&gt; time forbid. I recommend starting with the &lt;a href="https://sourceware.org/glibc/manual/latest/html_mono/libc.html#Main-Menu" rel="noopener noreferrer"&gt;table of contents&lt;/a&gt; in the manual, picking what seems interesting, and dig dig dig!&lt;/p&gt;

&lt;p&gt;At this point, you might be asking: “Okay dude, but what does the assembly &lt;em&gt;actually&lt;/em&gt; look like?” &lt;/p&gt;

&lt;p&gt;Were you actually thinking that? What a nerd. &lt;/p&gt;

&lt;p&gt;But fair enough, looking at this from a lower level of abstraction should help solidify what in Davey Jone’s locker is actually happening. Let’s look at some assembly.&lt;/p&gt;

&lt;h2&gt;
  
  
  A lower level of abstraction
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcc &lt;span class="nt"&gt;-S&lt;/span&gt; simple.c &lt;span class="nt"&gt;-o&lt;/span&gt; simple.S
&lt;span class="nb"&gt;cat &lt;/span&gt;simple.S
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Outputs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    .file    "simple.c"
    .text
    .globl    main
    .type    main, @function
main:
.LFB0:
    .cfi_startproc
    pushq    %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    call    fork@PLT        ; look at me, look at me, im mr meeseeks look at me!!
    movl    $0, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size    main, .-main
    .ident    "GCC: (GNU) 15.2.1 20260209"
    .section    .note.GNU-stack,"",@progbits
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At runtime, when the &lt;code&gt;call&lt;/code&gt; assembly instruction is executed, our CPU will start executing fork in libc. Where do we find fork? We can use the &lt;a href="https://www.man7.org/linux/man-pages/man1/ldd.1.html" rel="noopener noreferrer"&gt;ldd command&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcc simple.c &lt;span class="nt"&gt;-o&lt;/span&gt; simple
ldd simple 
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    linux-vdso.so.1 (0x00007f7edfa0f000)
    libc.so.6 =&amp;gt; /usr/lib/libc.so.6 (0x00007f7edf7f2000)
    /lib64/ld-linux-x86-64.so.2 =&amp;gt; /usr/lib64/ld-linux-x86-64.so.2 (0x00007f7edfa11000)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Awesome!  Next, let’s disassemble the libc.so.6 object file and find &lt;em&gt;exactly&lt;/em&gt; where the system call is made. Remember the _Fork() function? That should give us a clue of where to look. I’ll spare you the trouble of finding the precise location of the system call. This is what worked for me, feel free to tweak it to your heart’s content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;objdump &lt;span class="nt"&gt;-d&lt;/span&gt; /usr/lib/libc.so.6 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 1 &lt;span class="s2"&gt;"&amp;lt;_Fork@@GLIBC_2.34&amp;gt;"&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; 56 | &lt;span class="nb"&gt;nl&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;     1    00000000000e4840 &amp;lt;_Fork@@GLIBC_2.34&amp;gt;:
     2       e4840:    f3 0f 1e fa              endbr64
     3       e4844:    55                       push   %rbp
     …
    20       e4881:    b8 38 00 00 00           mov    $0x38,%eax
    21       e4886:    0f 05                    syscall
     …

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;See line 21? Lovely, isn't it? &lt;/p&gt;

&lt;p&gt;How do we know &lt;em&gt;that's&lt;/em&gt; the system call we're looking for? You can tell what system call a program makes by looking at the value in the EAX register right before the &lt;code&gt;syscall&lt;/code&gt; assembly instruction (or &lt;code&gt;int 0x80&lt;/code&gt; on a 32-bit ISA). &lt;/p&gt;

&lt;p&gt;In our case, we're moving the value &lt;code&gt;0x38&lt;/code&gt; into our EAX register.&lt;/p&gt;

&lt;p&gt;When the &lt;code&gt;syscall&lt;/code&gt; assembly instruction is executed, a couple of things happen very, very quickly. The important bits are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the value of some &lt;a href="https://en.wikipedia.org/wiki/Model-specific_register" rel="noopener noreferrer"&gt;MSR register&lt;/a&gt; into the CS register for privilege escalation from user mode to kernel mode. &lt;/li&gt;
&lt;li&gt;Save the instruction pointer in the RIP register to the RCX register for returning from our syscall.&lt;/li&gt;
&lt;li&gt;Read the values of some MSR register into the RIP register to point to the kernel's virtual address where we’ll resume execution.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These are just the highlights, if you want to learn more I recommend taking a look at your CPU manufacturer’s Software Developer Manual. I also recommend a dark roast triple shot espresso – you’ll need it. &lt;a href="https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html#:~:text=Intel%C2%AE%2064%20and%20IA%2D32%20Architectures%20Software%20Developer,767375.%20*%20Updated%202/27/2026.%20*%20Version%20Latest." rel="noopener noreferrer"&gt;For my ISA, the manual is provided by intel.&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That pretty much wraps things up! At this point you’ll find yourself executing code within the kernel itself. What code exactly depends on your architecture but for 64 bit systems on X86 you’ll want to take a look at the &lt;a href="https://elixir.bootlin.com/linux/v6.19.8/source/arch/x86/entry/entry_64.S" rel="noopener noreferrer"&gt;entry_64.S assembly file&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;Here is that diagram again, should make a lot more sense now!&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2F2026%2FLife%2520of%2520a%2520process%2Fdiagrame.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2F2026%2FLife%2520of%2520a%2520process%2Fdiagrame.png" width="639" height="632"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Kernel POV
&lt;/h2&gt;

&lt;p&gt;For the curious, this is what things look like from a very, very high level on the kernel side: &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;From the entry point, you’re routed to the system call handler &lt;/li&gt;
&lt;li&gt;From the system call handler you’re routed to the function for cloning your parent process:  &lt;code&gt;kernel_clone&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;From &lt;code&gt;kernel_clone&lt;/code&gt; you’ll first clone your process and then ship it off to the scheduler. &lt;/li&gt;
&lt;li&gt;The schedule will run the child process eventually. &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Thanks for reading, and keep digging!&lt;/p&gt;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;Another approach worth mentioning, but beyond our scope, are processes created through the OS at boot time (i.e, &lt;a href="https://unix.stackexchange.com/a/361255]" rel="noopener noreferrer"&gt;the idle process with PID 0&lt;/a&gt; and &lt;a href="https://linux.wiki/docs/commands/system-service/init/" rel="noopener noreferrer"&gt;the init system&lt;/a&gt; with PID 1). The mechanism behind these are quite interesting and if you’d like to learn more I recommend &lt;a href="https://0xax.gitbooks.io/linux-insides/content/Initialization/" rel="noopener noreferrer"&gt;this excellent chapter from the Linux Insides online textbook&lt;/a&gt;. ↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn2"&gt;
&lt;p&gt;To be more precise, fork() is a function in the posix standard library, which is part of libc. Libc is part of glibc; also known as Gnu libc (or as I've recently taken to call it, GNU plus libc).   ↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn3"&gt;
&lt;p&gt;Glibc has an algorithm for figuring out what code to compile for your system beforehand. &lt;a href="https://sourceware.org/glibc/manual/latest/html_mono/libc.html#Layout-of-the-sysdeps-Directory-Hierarchy" rel="noopener noreferrer"&gt;It’s not too complicated.&lt;/a&gt;  ↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&gt;

</description>
      <category>linux</category>
      <category>programming</category>
      <category>assembly</category>
      <category>c</category>
    </item>
    <item>
      <title>How much torment can my little homelab take? Part 1.</title>
      <dc:creator>Ernesto Enriquez</dc:creator>
      <pubDate>Thu, 12 Mar 2026 12:22:04 +0000</pubDate>
      <link>https://forem.com/ernesto905/how-much-torment-can-my-little-homelab-take-part-1-hlg</link>
      <guid>https://forem.com/ernesto905/how-much-torment-can-my-little-homelab-take-part-1-hlg</guid>
      <description>&lt;h4&gt;
  
  
  My setup ain’t much. I have a laptop running Arch and a desktop running Debian. I'm worth a grand total of 32 gigs of ram, 24 CPU cores, and 6 feet of a cat8 Ethernet Cable.
&lt;/h4&gt;




&lt;p&gt;There’s a gnarly little question gnawing at my &lt;a href="https://www.youtube.com/watch?v=SmaTPPB-T_s&amp;amp;t=318s" rel="noopener noreferrer"&gt;nucleus accumbens&lt;/a&gt; . How many requests per second can my $700 setup handle? What about reads per second, or writes!?&lt;/p&gt;

&lt;p&gt;Assuming a Java web application and relational database, can it handle, say, 10,000 of each?&lt;/p&gt;

&lt;p&gt;Probably not! In fact, it’s a ridiculous suggestion. I mean, what am I– crazy? Naive? Blissfully unaware of the economic state of consumer hardware? Well, I’m going to try it anyway! &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fun fact:&lt;/strong&gt; 10k req/sec is about four times what &lt;a href="https://nickcraver.com/blog/2016/02/17/stack-overflow-the-architecture-2016-edition/" rel="noopener noreferrer"&gt;stack overflow was doing back in 2016&lt;/a&gt; with bare-metal, enterprise level hardware.&lt;br&gt;
&lt;strong&gt;Fun fact 2:&lt;/strong&gt; One of my homelab’s fans doesn’t work. Thought you might find that mildly amusing.&lt;/p&gt;

&lt;p&gt;The architecture is simple. &lt;a href="https://github.com/spring-projects/spring-petclinic" rel="noopener noreferrer"&gt;Spring pet clinic&lt;/a&gt; is a sample MVC app that uses PostgreSQL for storage. &lt;a href="https://spring-petclinic-889427590364.europe-west9.run.app/" rel="noopener noreferrer"&gt;Here is what it looks like deployed&lt;/a&gt;. I’ll be using Grafana K6 for stress testing. &lt;/p&gt;

&lt;p&gt;I’ll be targeting the following endpoints: &lt;/p&gt;

&lt;p&gt;GET “/” – requests per second. &lt;br&gt;
GET /owners/{ownerID} – triggers a read to Postgres. &lt;br&gt;
POST /owners/new – triggers a write to Postgres. &lt;/p&gt;

&lt;p&gt;Given that the JVM is notorious for having a cold start problem&lt;sup id="fnref1"&gt;1&lt;/sup&gt;, we’ll be including a ramp up period for each stress test. We’ll start with 1% max rps -&amp;gt; 10% max rps -&amp;gt; 50% max rps -&amp;gt; 100% max rps. We’ll spend 30 seconds ramping up to each stage and hold our max stage for 60 seconds. &lt;/p&gt;

&lt;p&gt;For example, at 1k rps: &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Floadexample.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Floadexample.png"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I decided to containerize everything from the get go and deploy it on Minikube, but I’ll eventually move to bare metal or k3s since it’s more resource optimized. Also, I’ll start with a single node at first and add a second machine later (probably in continuation post). It’s a journey, after all. &lt;/p&gt;

&lt;p&gt;Anyways, did you catch all that? Here, look some Excalidraw: &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Farchitecture.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Farchitecture.png"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I’ll be writing down findings, headaches, and optimizations I make along the way.  &lt;/p&gt;

&lt;p&gt;Let’s see, am I forgetting anything before we start? &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[x] State goal &lt;/li&gt;
&lt;li&gt;[x] Describe application and infrastructure &lt;/li&gt;
&lt;li&gt;[x] Scatter droll remarks across the article and mention that I use Arch Linux at least twice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Oh yeah! Our only SLO is 0% request drop rate under the target load. In other words, a single queued request not handled by the end of a run yields a big fat failure.&lt;/p&gt;
&lt;h3&gt;
  
  
  1 X per second
&lt;/h3&gt;

&lt;p&gt;As you might have guessed, this was pretty much smooth sailing. I’m going to show the 9 experiments back to back for this one, since the throughput is so low. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Fonexpersec.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Fonexpersec.png"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first three tests are req/sec, then reads, then writes. &lt;/p&gt;

&lt;p&gt;That latency spike at the start was the JVM warming up. Two more (smaller) latency spikes follow when we switch to reads and writes. They’re different code paths, after all! &lt;/p&gt;

&lt;p&gt;Here are the Postgres metrics: &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Fonexpersec2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Fonexpersec2.png"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now, let’s turn up the heat. &lt;/p&gt;
&lt;h3&gt;
  
  
  1000 X per second
&lt;/h3&gt;
&lt;h4&gt;
  
  
  &lt;em&gt;Requests&lt;/em&gt;
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2F1000req.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2F1000req.png"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All systems are nominal. Take a look at those latency scores, though! Each time we started the load test, you’d see a hit to performance, and then a massive, sharp improvement. They don’t call it Hotspot for nuthin. &lt;/p&gt;
&lt;h4&gt;
  
  
  Next, let’s do some reads.
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2F1000jvmreads.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2F1000jvmreads.png"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2F1000writes.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2F1000writes.png"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All systems nominal part 2.&lt;/p&gt;

&lt;p&gt;Honestly I didn't think I'd make it this far with the default Minikube limits (2 cores and 2GB of memory). 1k requests per second is a little over 86 million requests per day. Pretty grande numero, compadre. &lt;/p&gt;
&lt;h3&gt;
  
  
  Writes:
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2F1000writesjvm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2F1000writesjvm.png"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2F100writespostgres.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2F100writespostgres.png"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It didn't work! Of the 3 experiments, only the second managed to write the 60k records into Postgres. &lt;/p&gt;

&lt;p&gt;So, why did run 1 and 3 fail? Two metrics immediately stand out. &lt;/p&gt;

&lt;p&gt;First, take a look at the thread state (chart 1 row 2 column 3). For both of the poo-poo runs, the number of threads in the “Timed waiting” state hit 200.&lt;/p&gt;

&lt;p&gt;Second, the average memory usage (chart 2 row 1 column 2) for the database dashboard tells a similar story, peaking at around the same time our number of timed waiting threads hit 200.&lt;/p&gt;

&lt;p&gt;We know that Java Spring defaults to Tomcat as its web server. Out of the box, Tomcat limits the maximum number of Worker Threads to 200. Each request gets its own worker thread. Worker threads are a wrapper for the OS threads. We’re hitting that limit around the same time everything breaks.&lt;/p&gt;


&lt;p&gt;&lt;br&gt;
    &lt;strong&gt; Hypothesis #1:&lt;/strong&gt; We can solve this problem by increasing Tomcat’s thread limit.&lt;br&gt;
  &lt;/p&gt;

&lt;p&gt;If I had to be perfectly honest, increasing the number of worker threads smells a little funky. Kinda feels like slapping a bandaid on a gash that needs stitches. Or, it might be closer to slapping a band aid on a radiation burn. More operating system threads means having to worry about the cost of thread context switches, which means higher cpu utilization, which introduces latency, which as we’ve discussed before makes puppies cry&lt;sup id="fnref2"&gt;2&lt;/sup&gt;. You wouldn’t want to make puppies cry, would you? &lt;/p&gt;

&lt;p&gt;There is a more modern take on thread pool exhaustion. Virtual threads are a Java 21+ feature. Here, &lt;a href="https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html" rel="noopener noreferrer"&gt;read the friendly manual to learn more&lt;/a&gt;. &lt;/p&gt;


&lt;p&gt;&lt;br&gt;
    &lt;strong&gt; Hypothesis #2:&lt;/strong&gt; We can solve this problem by introducing virtual threads. &lt;br&gt;
  &lt;/p&gt;

&lt;p&gt;Another reason may be the nature of the work itself. What if our threads are presently caught up in some sort of I/O? Say a request comes in. One of our threads might be like “i got this bro”. That thread then goes to the connection pool for postgres and opens a connection. Nine more threads do just this, exhausting our default connection pool of 10.&lt;/p&gt;

&lt;p&gt;Requests are still coming in, though. Any thread without access to a connection to postgres goes into the TIMED_WAITING state. So even if we increase the number of threads or switch to virtual threads, this would not help as much as, say, increasing the size of the connection pool. I could be wrong though, let’s throw some scientific method at it and see what happens!&lt;/p&gt;


&lt;p&gt;&lt;br&gt;
    &lt;strong&gt; Hypothesis #3:&lt;/strong&gt; We can solve this problem by increasing the HikariCP connection pool size.&lt;br&gt;
  &lt;/p&gt;

&lt;p&gt;Now, say none of these work. In which case, the problem is most likely CPU throttling. Postgres is trying to commit a thousand records per second on two cpu cores, the poor little guy. I’d prefer to delay throwing more compute at the problem for as long as possible. But if we simply can’t handle the load, then we’ll brute force our way through and leave more time consuming optimizations for later. &lt;/p&gt;


&lt;p&gt;&lt;br&gt;
    &lt;strong&gt; Hypothesis #4:&lt;/strong&gt; We can solve this problem by increasing the number of cpus (cores).&lt;br&gt;
  &lt;/p&gt;

&lt;p&gt;Pause here and take a crack at guessing what happens when we: &lt;/p&gt;

&lt;p&gt;(1) increase the maximum number of tomcat worker threads 200-&amp;gt;400 &lt;br&gt;
(2) switch to virtual threads &lt;br&gt;
(3) increase the hikari cp connection pool size 10-&amp;gt;50&lt;br&gt;
(4) increase the number of cores 2-&amp;gt;12&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Did you take a guess? Please take a guess. Please. Come on, man. Think of the kids.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Hypothesis #1 result: &lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Fres1part1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Fres1part1.png"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;it didn’t work! Oddly enough, we ended up using the same number of threads. I did a little digging and it turns out the threads state metric captures tomcat workers + JVM internal threads. In other words, we either weren’t reaching our limit on the Tomcat side at all, Tomcat decided it didn’t need to spawn more worker threads, or something else. I humbly regret to inform you that I'm leaning towards something else. It seems figuring out exactly why this happens requires further observability/instrumentation (with my luck, it will turn out to be something quite obvious) and knowing the cause (probably) wouldn’t get us a boost in performance, so I'm moving on! &lt;/p&gt;

&lt;p&gt;Hypothesis #2 result: &lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Fres1part2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Fres1part2.png"&gt;&lt;/a&gt;&lt;br&gt;
Well, we fixed the thread pool exhaustion issue. But no cigar. We’re still not hitting those 60k writes. Interestingly enough, our p99 latencies are looking pretty scrumptious compared to using the non-virtual threads. Seems virtual threads may be part of a late game meta? &lt;/p&gt;

&lt;p&gt;Hypothesis #3 result: Great news everyone! It didn’t work! &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Fres3part1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Fres3part1.png"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Fres3part2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Fres3part2.png"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Fres3part3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Fres3part3.png"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I think this may have actually been our worst performance. The mechanics of why that happened is actually pretty interesting, albeit beyond the scope of this article. But here, if you want to learn more, &lt;a href="https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing" rel="noopener noreferrer"&gt;click me!&lt;/a&gt; &lt;a href="https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing" rel="noopener noreferrer"&gt;Or me!&lt;/a&gt; Or &lt;a href="https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing" rel="noopener noreferrer"&gt;even me!&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;TLDR; You should set the number of connections within a pool to&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;&lt;code&gt;(cpu cores * 2) + effective spindle count&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;If you make it any bigger, then you’re increasing the thread count to your detriment. In other words, context switches are gonna get cha’. Sometimes, less is more!&lt;/p&gt;

&lt;p&gt;Hypothesis 4 result: Okay, this one &lt;em&gt;actually&lt;/em&gt; worked! &lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Fres4part1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Fres4part1.png"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Fres4part2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Fres4part2.png"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It seems like 1k writes per second is too much for Minikube’s default 2 cpu cores after all. Whooda thunk?  &lt;/p&gt;

&lt;p&gt;And as a final sanity check&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Fres4part3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Fres4part3.png"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Perfect! &lt;/p&gt;

&lt;p&gt;Okay, let’s do 2.5k now. We’ll keep 12 cores and up the memory to 12GiB. We’ll also stick to the default thread settings for now. I want to see how far that’ll take us. &lt;/p&gt;

&lt;h3&gt;
  
  
  2500 X per second
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Requests
&lt;/h4&gt;

&lt;p&gt;Utter failure. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2F2500requests.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2F2500requests.png"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Notice anything different about these charts than our initial results for 1k writes per second? First, we're capping out a little over 2.2-ish thousand requests per second; that is, we never actually hit our goal. Second, our thread states are spiky. During writes, we’d plateau at our limit of 200 threads in the timed-waiting state. Third, our latency distributions are suspiciously consistent. Not really something you’d expect from a system crumbling under an unbearably girthy throughput. &lt;/p&gt;

&lt;p&gt;Another thing you might notice from our thread states is the new spike of threads in the “Runnable” state. &lt;/p&gt;

&lt;h3&gt;
  
  
  I give up.
&lt;/h3&gt;

&lt;p&gt;I don’t think we’re going to hit 2500 RPS today, but I want to leave things off with a few observations and next steps. &lt;/p&gt;

&lt;p&gt;There are a couple of questions whose answers would greatly help going into part 2. I ran 3 more tests for each category and here are the numbers (worst case):&lt;/p&gt;

&lt;h4&gt;
  
  
  Requests
&lt;/h4&gt;

&lt;p&gt;What is our p(99) at a manageable load (1000 RPS)?&lt;/p&gt;

&lt;p&gt;-&amp;gt; 15.26ms&lt;/p&gt;

&lt;p&gt;What is the max latency at a manageable load (1000 RPS)? &lt;/p&gt;

&lt;p&gt;-&amp;gt; 191.84ms &lt;/p&gt;

&lt;p&gt;At what throughput does the request queue start growing (i.e, the arrival rate &amp;gt; queue service rate)? &lt;/p&gt;

&lt;p&gt;-&amp;gt; 2083 requests per second&lt;/p&gt;

&lt;h4&gt;
  
  
  Reads
&lt;/h4&gt;

&lt;p&gt;What is our p(99) at a manageable load (1000 Reads per second)?&lt;/p&gt;

&lt;p&gt;-&amp;gt; 10.58ms&lt;/p&gt;

&lt;p&gt;What is the max latency at a manageable load (1000 Reads per second)? &lt;/p&gt;

&lt;p&gt;-&amp;gt; 27ms&lt;/p&gt;

&lt;p&gt;At what throughput does the request queue start growing (i.e, the arrival rate &amp;gt; queue service rate)? &lt;/p&gt;

&lt;p&gt;-&amp;gt; 1818 reads per second&lt;/p&gt;

&lt;h4&gt;
  
  
  Writes
&lt;/h4&gt;

&lt;p&gt;What is our p(99) at a manageable load (1000 writes per second)?&lt;/p&gt;

&lt;p&gt;-&amp;gt; 10.25ms&lt;/p&gt;

&lt;p&gt;What is the max latency at a manageable load (1000 writes per second)? &lt;/p&gt;

&lt;p&gt;-&amp;gt; 209ms&lt;/p&gt;

&lt;p&gt;At what throughput does the request queue start growing (i.e, the arrival rate &amp;gt; queue service rate)? &lt;/p&gt;

&lt;p&gt;-&amp;gt; 2041 writes per second &lt;/p&gt;

&lt;h4&gt;
  
  
  On growing queues
&lt;/h4&gt;

&lt;p&gt;We know the request queue is growing by the number of virtual users (VUs) that dynamically spin up during our load tests. Virtual users are an abstraction provided by Grafana k6 that simulates requests from real users. I used &lt;a href="https://en.wikipedia.org/wiki/Little%27s_law" rel="noopener noreferrer"&gt;Little’s law&lt;/a&gt; to set the initial number of VUs. Grafana K6 dynamically increases the number of VUs up to some max number (which I set to 500). My guess is they use Little’s law or something similar. In other words, more latency -&amp;gt; more VUs dynamically allocated. Under the hood, virtual users are goroutines. Goroutines are similar to Java’s virtual threads in their purpose – concurrency through abstraction. The cost of too many goroutines (and therefore VUs) is memory. All this to say I’m monitoring the logs from K6 for spikes in the number of dynamically allocated VUs as a sign/symptom that the number of requests are greater than what the Java application can handle. For example, within a matter of 5 seconds, the number of VUs shoots from 3 to our maximum of 500. Across three runs, the experiment below began to queue around the 1950 iterations per second mark. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Fvuexplanation.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyoeahnfntijezaoargpm.supabase.co%2Fstorage%2Fv1%2Fobject%2Fpublic%2FBlog%2520images%2FTormenting%2520homelab%2520series%2Fvuexplanation.png"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Moving forward
&lt;/h3&gt;

&lt;p&gt;I think I’ve run out of low hanging fruit. I’m planning on making the following changes for part two: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Increase to two nodes. I’ll run pg_bench on a containerized postgres instance for both of my machines. Whichever SSD of the two performs better in terms of Transactions per second (TPS) will host postgres. &lt;/li&gt;
&lt;li&gt;Switch off minikube to a lighter distribution. Namely, k3s, which is made for IoT, edge devices, and sad, penurious homelabs :( &lt;/li&gt;
&lt;li&gt;Come in with some application level data on where the bottlenecks are at runtime. I’ll be using asyncprofiler. &lt;/li&gt;
&lt;li&gt;Generally drink more water and get more sleep&lt;/li&gt;
&lt;li&gt;Go back to virtual threads. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Until next time! &lt;/p&gt;




&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;At runtime, the JVM’s JIT Compiler turns bytecode into “optimized” native machine code. Optimized processes are good because they utilize less CPU cycles to perform certain instructions. Meaning, we have more cpu cycles left over to perform more work. However, If we bombard the REST API with an onslaught of requests before this optimization takes place then the cpu utilization skyrockets, leading to CPU throttling -&amp;gt; higher latency -&amp;gt; unhappy users -&amp;gt; and crying baby puppies. ↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn2"&gt;
&lt;p&gt;See footnote one right above me. What, you don't read footnotes? Do you think you're better than me or something? ↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&gt;

</description>
      <category>linux</category>
      <category>sre</category>
      <category>devops</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
