[GSoC 2026] Task and TaskGroup tracking for Swift Concurrency

Hello everyone! :waving_hand:

My name is Ege Kaya, and I’m incredibly excited to share what I've been working on over the summer for my Google Summer of Code 2026 project alongside my fantastic mentors, @al45tair and @Mike_Ash!

My project focused on introducing Task and TaskGroup tracking for the Swift Concurrency runtime, which will hopefully vastly improve the debugging experience for Swift developers!

The Problem:

Until now, the Swift Concurrency runtime did not provide a built-in way to keep track of which Tasks and TaskGroups are currently executing (and which are stuck). This missing information made debugging programs that use Swift Concurrency notoriously difficult. If your application ended up in a state where no progress was being made, you couldn't easily see which tasks were outstanding because they weren't actively executing on a thread (and thus didn't show up in backtraces).

An easy, naive solution might have been to use a global linked list of all Tasks and TaskGroups. However, that approach would cause significant, unnecessary synchronization and lock contention between threads, which is highly undesirable for a high-performance concurrency runtime.

The Solution:

We designed and implemented a low-overhead data structure within the runtime to track tasks.

The TaskRegistry

Instead of a single global lock, we built a highly concurrent sharded registry data structure (TaskRegistry).

  • We distribute the tasks across multiple independent shards (TaskRegistryShard), which are aligned to the CPU cache line size (SWIFT_CACHE_LINE_SIZE) to prevent false sharing.
  • Each shard contains its own intrusive doubly-linked list of AsyncTask objects and a LazyMutex.
  • By hashing tasks into different shards based on their unique Task ID, we significantly reduce thread contention when tasks are spawned or destroyed simultaneously.

This allows us to maintain a registry of all live tasks with minimal performance overhead, laying the foundation for powerful concurrency debugging tooling!

Under the Hood: Memory and Crash-Safety

We wrote roughly 600 lines of C++ code to implement this in the Swift Runtime, focusing heavily on performance and memory safety.

Here are some of the most interesting details:

1. Intrusive Storage (Zero Mallocs):
To ensure this tracking has virtually zero overhead, we avoided allocating wrapper nodes for each task. Instead, we injected registryNext and registryPrev pointers directly into the PrivateStorage struct inside AsyncTask. By doing this, inserting a task into the registry only involves updating pointers during the existing task allocation cycle—completely eliminating malloc/free overhead and reducing the per-task registration cost to an imperceptible ~0.01 μs.

2. O(1) Insertions and Removals:
When taskRegistryInsert or taskRegistryRemove are called during the task lifecycle, we hash the 64-bit Task ID (taskId ^ (taskId >> 8)) & 63 to pick one of the 64 shards. We grab that shard's LazyMutex lock and perform an O(1) doubly-linked list mutation. Because AsyncTask acts as its own node, we can surgically sever its registryPrev and registryNext connections instantly when the task finishes.

3. Crash-Safe Traversal:
If a process crashes or is paused by LLDB, the debugger relies on _swift_concurrency_debug_task_registryWalk to read the state. Because crashes can happen while a lock is held, this function uses mutex.try_lock() across all shards first. It strictly skips any shards it cannot lock and implements cycle-limit protections to prevent infinite loops, guaranteeing that the debugger can safely read the task state even if the program memory is corrupted.

To functionally verify the registry, we can run a raw python script in LLDB that walks all 64 shards directly in memory. This raw output also proves that tasks are successfully hashing into independent shards, effectively eliminating contention:

Real-World Validation (The Web Server Test)

When looking at other GSoC posts, they often discuss the challenges or real-world testing they performed. To ensure our implementation wouldn't accidentally degrade performance in production, we built a custom benchmark simulating a high-throughput web server routing engine.

The workload queued 100,000 incoming requests, processing up to 200 simultaneously, and rapidly spawned over 600,000 AsyncTask objects in a short burst. The results?

  • Baseline (Registry OFF): 0.3715 seconds
  • Enabled (Registry ON): 0.3860 seconds

The registry successfully tracked over half a million highly-concurrent tasks spanning massive task groups with completely negligible runtime impact!

Things I Learnt

  • Concurrency Runtime Internals (C++): Gained a deep understanding of how the Swift concurrency model works under the hood, from task allocation to dispatching.
  • Performance Engineering: Learned to hunt down microsecond overheads, utilize tools to measure cache-line false sharing, and understand the cost of atomics vs. locks.
  • LLDB Python Scripting: Wrote Python scripts to interact with the LLDB API, reading runtime memory and traversing complex C++ structures during a debugging session.

What's Left (Stretch Goals):

While the core runtime support is completely done and merged, I am currently working on the stretch goals for this project and hopefully will get them done soon.

  • On-crash Backtraces: Implementing the necessary support to provide a detailed list of all extant Tasks and TaskGroups in Swift’s on-crash backtraces. Here's a sneak peek:

  • LLDB Support: Providing Python macros for LLDB that can list all live Tasks and TaskGroups directly from the debugger. Here's a sneak peek:

Merged Pull Requests

Here are the pull requests that were merged as part of this project:

Closing Thoughts

This GSoC experience has been absolutely transformative. I got to dive deep into the Swift runtime (C++), work with atomics and memory ordering, and contribute to a feature that will help developers debug their concurrency issues.

A huge thank you to my mentors, @al45tair and @Mike_Ash. Beyond their invaluable technical guidance and thorough code reviews, they were incredibly warm and welcoming. Some of the best parts of my summer were simply chatting with them about all things computers!

Feel free to explore the code!

All the best,
Ege Kaya

19 Likes

Thanks for the great writeup and your work during GSoC overall!

I've been looking at the PR for a while and the impl approach is really good, nice work :+1:

1 Like

This is really great work, and the writeup was very pleasant to read! :)

Let me just throw an idea here, in case you want to go beyond LLDB's python scripting. A while ago, we implemented language swift task {tree, list} commands that takes all Tasks currently assigned to some thread, and follow the parent-child edges in the Task graph, as well as the blocked-on edges, to find as many Task as possible and print them. There is an example of the output in the commit description here.

(lldb) task tree
├╴ Task 1, addr = 0x100655740 [awaiting Task 2] [suspended]
│    frame #0: Task<>.value.getter
│    frame #1: async_MainTQ0_ + 20 at fib.swift:25:25
└╴ Task 2 ('fib-main'), addr = 0x100657230 [awaiting Task 3] [suspended]
   │ frame #0: fib(_:) at fib.swift:13
   │ frame #1: closure #1 in  + 24 at fib.swift:22:9
   └╴ Task 3, addr = 0x100657c70 [running]
      │ frame #0: fib(_:) + 344 at fib.swift:10:11
      │ frame #1: implicit closure #2 in fib(_:) + 24 at fib.swift:6:18
(lldb) task tree --max-frames 0
├╴ Task 1, addr = 0x100655740 [awaiting Task 2] [suspended]
└╴ Task 2 ('fib-main'), addr = 0x100657230 [awaiting Task 3] [suspended]
   └╴ Task 3, addr = 0x100657c70 [running]

As you know, this approach is flawed because it won't discover tasks not reachable through said edges; your work is very exciting as we can finally close that gap.

This command is not implemented in terms of the python API, but rather inside LLDB itself. You're welcome to explore that code if you want -- I suspect the only thing you'd need to change is the root nodes used in the search done inside the constructor of TaskExplorer -- but I don't want to throw more work your way :)

You can see the start of the search I described ("all tasks assigned to a thread") in this code snippet, and I bet all we'd need to do is change this function:

  TaskExplorer(ReflectionContextInterface &reflection_ctx, Process &process)
      : m_reflection_ctx(reflection_ctx) {
    auto task_finder = GetTaskFinder(process);

    for (const ThreadSP &thread : process.GetThreadList().Threads()) {
      if (!thread)
        continue;
      std::optional<lldb::addr_t> maybe_task_addr =
          task_finder->GetTaskAddrForThread(*thread);
      if (!maybe_task_addr)
        continue;
      int32_t max_nodes = 1000;
      ExploreTask(*maybe_task_addr, max_nodes);
    }
  }

Instead of iterating over the thread list, we'd iterate over all the tasks in TaskRegistry (which would involve doing a global symbol lookup, etc.).

Anyhow, happy to provide some guidance if you're interested (feel free to say no, obviously! :)