Skip to content

macOS Execution: Mach Tasks vs. BSD Processes

A deep dive into the Darwin/XNU kernel. We explore the architectural divide between Mach Tasks and Threads, and how standard BSD POSIX APIs like fork() are illusions layered over Mach primitives.

If you’ve spent any time reading about operating systems, you’ve likely heard that macOS runs on a “hybrid kernel” known as XNU (X is Not Unix). But what does that actually mean when you compile and run a C program on your Mac?

When you call standard POSIX functions like fork() or pthread_create() on macOS, you are interacting with the BSD subsystem—a personality layer that provides a familiar UNIX environment. However, beneath that BSD veneer lies a completely different architectural beast: the Mach subsystem.

In this guide, we are going to tear down the BSD illusion. We will explore how the XNU kernel actually models execution, the fundamental difference between a Mach Task and a Mach Thread, and how standard UNIX process APIs are just wrappers around lower-level Mach primitives.

The Hybrid Architecture: Mach vs. BSD

To understand macOS execution, we must first understand its split personality.

XNU is composed of two primary components (alongside the I/O Kit driver framework):

  1. Mach: The foundational layer responsible for low-level system operations: memory management (VM), CPU scheduling, and Inter-Process Communication (IPC). Mach is entirely object-oriented (conceptually) and message-based.
  2. BSD: The upper layer that provides the POSIX APIs (file systems, networking, processes, signals).

When you write a standard UNIX program, you talk to the BSD layer. But BSD does not manage memory or schedule the CPU; it delegates those responsibilities to Mach.

The Mach Philosophy: Tasks and Threads

In traditional UNIX, a “process” is a monolithic entity that encapsulates both the memory space and the execution context.

Mach aggressively separates these concepts into Tasks and Threads.

The Mach Task: A Resource Container

A Mach Task is nothing more than a resource container. A task does not run. It cannot execute code. It is merely a collection of resources, primarily:

  • A virtual memory space.
  • A namespace of Mach Ports (used for IPC).
  • Exception ports (for debugging and crash handling).

Think of a Mach Task as a secure, isolated sandbox. It holds the memory and the communication channels, but it has no execution capability of its own.

The Mach Thread: The Execution Unit

A Mach Thread is the fundamental unit of execution in XNU. It represents the actual flow of control and contains:

  • CPU registers (Instruction Pointer, Stack Pointer, etc.).
  • A scheduling policy and priority.
  • A reference to the Mach Task it belongs to.

A task must contain at least one thread to do anything useful. When we run a program, XNU creates a Mach Task to hold the memory, and a Mach Thread inside that task to execute the code.

Peeking Under the Hood: Mach APIs in C

Let’s prove this separation exists. While we typically use getpid() to identify our process, we can bypass the BSD layer and ask Mach directly for our Task and Thread identifiers.

Mach exposes a C API that allows us to interact with the Mach subsystem directly.

mach_task_create.c c
#include <stdio.h>
#include <unistd.h>
#include <mach/mach.h>
#include <mach/task.h>
#include <mach/thread_act.h>
#include <pthread.h>

int main() {
  // 1. The BSD view: Process ID
  pid_t bsd_pid = getpid();
  
  // 2. The Mach view: Task and Thread Ports
  mach_port_t mach_task = mach_task_self();
  mach_port_t mach_thread = mach_thread_self();
  
  // 3. The POSIX Thread view
  pthread_t posix_thread = pthread_self();

  printf("BSD Process ID (PID): %d
", bsd_pid);
  printf("Mach Task Port:       %u
", mach_task);
  printf("Mach Thread Port:     %u
", mach_thread);
  printf("POSIX Thread ID:      %p
", (void *)posix_thread);

  // Clean up the thread port reference we acquired.
  // Note the asymmetry: mach_thread_self() mints a fresh send right on every call 
  // that we must deallocate, while mach_task_self() returns a cached right we must not!
  mach_port_deallocate(mach_task, mach_thread);

  return 0;
}

When you run this on a Mac, you’ll see four distinct identifiers.

Notice that mach_task_self() and mach_thread_self() return a mach_port_t. In Mach, everything is an object, and you interact with objects by sending messages to their Ports. Your “Task ID” is not a simple integer like a PID; it’s a handle to a communication channel that allows you to send control messages to the task object in the kernel.

The Illusion of fork()

In traditional UNIX (and Linux), the fork() system call is the primary way to create a new process. It clones the parent’s memory and execution state.

On macOS, fork() is a BSD concept. The Mach subsystem natively has no idea what a fork() is. So, how does XNU implement it?

When we call fork(), the BSD subsystem in the kernel acts as an orchestrator, translating our request into Mach primitives:

  1. Task Creation: The kernel calls [task_create_internal](https://github.com/apple/darwin-xnu/blob/main/osfmk/kern/task.c)() to create a new, empty virtual memory container.
  2. Memory Cloning: Mach’s virtual memory subsystem maps the parent’s memory into the new task using Copy-on-Write (COW).
  3. Thread Creation: The kernel calls thread_create_waiting() to instantiate a new thread inside the new task.
  4. State Duplication: The parent thread’s register state (including the Instruction Pointer) is copied into the new child thread, so it resumes execution at the exact same spot.
  5. BSD Bookkeeping: Finally, BSD allocates a new proc structure (the BSD representation of a process), assigns it a PID, copies file descriptors, and links it to the newly created Mach Task.

Note that these are direct function calls inside a single kernel, not Mach messages—XNU’s Mach and BSD halves share one address space, which is exactly what makes XNU a hybrid kernel rather than a true microkernel. The fork() call is a massive, complex wrapper that glues together a Mach Task, a Mach Thread, and a BSD proc structure to maintain the illusion of a standard UNIX environment.

Inspecting Task Threads

To truly visualize the Task/Thread relationship, we can use the Mach API to enumerate all threads running inside our task.

mach_threads.c c
#include <stdio.h>
#include <stdlib.h>
#include <mach/mach.h>

void print_task_threads() {
  mach_port_t task = mach_task_self();
  thread_act_array_t thread_list;
  mach_msg_type_number_t thread_count;

  // Ask Mach for a list of all threads in this task
  kern_return_t kr = task_threads(task, &thread_list, &thread_count);
  
  if (kr != KERN_SUCCESS) {
      fprintf(stderr, "task_threads failed: %s
", mach_error_string(kr));
      return;
  }

  printf("Task %u contains %u thread(s):
", task, thread_count);

  for (mach_msg_type_number_t i = 0; i < thread_count; i++) {
      printf("  [%u] Mach Thread Port: %u
", i, thread_list[i]);
      // Must deallocate the port rights we acquired
      mach_port_deallocate(task, thread_list[i]); 
  }

  // Deallocate the array of thread ports
  vm_deallocate(task, (vm_address_t)thread_list, thread_count * sizeof(thread_t));
}

int main() {
  print_task_threads();
  return 0;
}

If you compile and run this code, it will likely report more than one thread if it’s linked against high-level frameworks like Foundation, because runtimes often spawn background threads (e.g., libdispatch).

However, if you attach a debugger (like lldb), it does not inject a thread into your task to handle exceptions. Instead, debugserver allocates an exception receive port in its own task and calls task_set_exception_ports() on your target. When your program faults, the kernel converts the exception into a Mach message sent to that port.

This is the power of the Mach architecture. Debuggers don’t have to rely primarily on complex BSD signal hacks; they can simply receive faults as messages in their own process, which is a genuinely cleaner design.

Conclusion

When developing for macOS or iOS, we rarely interact with the Mach layer directly. The BSD subsystem (and higher-level frameworks like Grand Central Dispatch or pthreads) does an excellent job of abstracting away the Mach complexity.

However, as systems engineers, we must understand the actual mechanics of the machines we program. A macOS process is not a monolithic entity; it is a BSD proc structure sitting on top of a Mach Task, which in turn acts as a sandbox for one or more Mach Threads.

Because fork() is just an illusion glued over these Mach primitives, there is a severe practical consequence: on Darwin, fork() is only safe until exec. If a process uses Mach ports, Core Foundation, the Objective-C runtime, or GCD, and then calls fork(), the child process risks deadlock or memory corruption because that Mach-level and framework-level state was not properly forked. The child must immediately call an exec* function to replace its image. This is precisely why posix_spawn is the recommended API for spawning new processes on macOS.

Coder Musings

A modern technical laboratory for systems programming. Master Assembly, Compilers, and Low-Level Engineering through curated paths and interactive visualizations.

© 2026 Coder Musings. All rights reserved. Built for the systems community.