multithreading
cycle detection
directed graphs
algorithms
computer science

multithreaded algo for cycle detection in a directed graph

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Cycle detection in a directed graph is easy to explain in one thread and much harder to parallelize correctly. The usual depth-first search algorithm relies on shared visitation state, and careless multithreading turns that state into a race condition.

If your real goal is to answer "does this graph contain a cycle?", a better parallel strategy is usually based on repeated removal of zero in-degree vertices. That approach avoids recursive shared stacks and maps well to worker threads.

Why Parallel DFS Is Hard

The classic single-threaded solution marks each node with one of three colors: unvisited, visiting, and done. A back-edge into a visiting node means there is a cycle.

That works because one thread owns the traversal order. In a multithreaded DFS, several problems appear immediately:

  • two threads may try to visit the same node at once
  • one thread may observe stale color state
  • cross-partition edges can hide cycles if each thread only sees its local subgraph

You can add locks around the color array, but heavy locking often removes most of the performance benefit. Worse, it is easy to produce an algorithm that seems fast but occasionally misses a cycle.

A Better Parallel Strategy: In-Degree Peeling

Kahn's algorithm is normally taught for topological sorting, but it also gives a clean cycle test:

  1. Compute the in-degree of every vertex.
  2. Put every zero in-degree vertex into a work queue.
  3. Repeatedly remove a ready vertex and decrement the in-degree of its outgoing neighbors.
  4. If all vertices are removed, the graph is acyclic.
  5. If vertices remain with positive in-degree after the queue is empty, the graph contains a cycle.

This is easier to parallelize because workers can safely process different ready vertices at the same time. The main shared data is:

  • a concurrent queue of ready vertices
  • an atomic in-degree counter for each vertex
  • a processed counter

The method does not directly return the cycle path, but it is a strong first pass for large graphs. If a cycle exists, you can run a second algorithm on the remaining vertices to recover an actual cycle.

Java Example

The Java program below uses a fixed thread pool, a concurrent queue, and AtomicIntegerArray to perform parallel in-degree peeling. It prints whether the graph contains a cycle.

java
1import java.util.List;
2import java.util.concurrent.ConcurrentLinkedQueue;
3import java.util.concurrent.CountDownLatch;
4import java.util.concurrent.ExecutorService;
5import java.util.concurrent.Executors;
6import java.util.concurrent.atomic.AtomicInteger;
7import java.util.concurrent.atomic.AtomicIntegerArray;
8
9public class ParallelCycleCheck {
10    static boolean hasCycle(List<List<Integer>> graph, int workers) throws InterruptedException {
11        int n = graph.size();
12        AtomicIntegerArray indegree = new AtomicIntegerArray(n);
13
14        for (int u = 0; u < n; u++) {
15            for (int v : graph.get(u)) {
16                indegree.incrementAndGet(v);
17            }
18        }
19
20        ConcurrentLinkedQueue<Integer> ready = new ConcurrentLinkedQueue<>();
21        for (int i = 0; i < n; i++) {
22            if (indegree.get(i) == 0) {
23                ready.add(i);
24            }
25        }
26
27        AtomicInteger processed = new AtomicInteger(0);
28        AtomicInteger activeWorkers = new AtomicInteger(0);
29        CountDownLatch done = new CountDownLatch(workers);
30        ExecutorService pool = Executors.newFixedThreadPool(workers);
31
32        for (int i = 0; i < workers; i++) {
33            pool.submit(() -> {
34                try {
35                    while (true) {
36                        Integer u = ready.poll();
37                        if (u == null) {
38                            if (activeWorkers.get() == 0 && ready.isEmpty()) {
39                                break;
40                            }
41                            Thread.yield();
42                            continue;
43                        }
44
45                        activeWorkers.incrementAndGet();
46                        processed.incrementAndGet();
47
48                        for (int v : graph.get(u)) {
49                            if (indegree.decrementAndGet(v) == 0) {
50                                ready.add(v);
51                            }
52                        }
53
54                        activeWorkers.decrementAndGet();
55                    }
56                } finally {
57                    done.countDown();
58                }
59            });
60        }
61
62        done.await();
63        pool.shutdown();
64        return processed.get() != n;
65    }
66
67    public static void main(String[] args) throws InterruptedException {
68        List<List<Integer>> acyclic = List.of(
69            List.of(1, 2),
70            List.of(3),
71            List.of(3),
72            List.of()
73        );
74
75        List<List<Integer>> cyclic = List.of(
76            List.of(1),
77            List.of(2),
78            List.of(0)
79        );
80
81        System.out.println(hasCycle(acyclic, 4));
82        System.out.println(hasCycle(cyclic, 4));
83    }
84}

The output is false for the acyclic graph and true for the cyclic graph. On a large graph, the advantage comes from many zero in-degree vertices being processed at the same time.

Common Pitfalls

The most common pitfall is trying to parallelize recursive DFS without redesigning the data model. Shared recursion state, visitation colors, and parent stacks become synchronization hazards almost immediately.

Another mistake is ignoring graph shape. A long chain offers very little parallelism because only one node becomes ready at a time. A graph with many independent branches exposes much more concurrency. Parallel performance depends heavily on the frontier width, not only on the number of vertices.

It is also easy to confuse "cycle detection" with "cycle reconstruction". In-degree peeling tells you whether a cycle exists, but not the exact cycle. If you need the path, run a second pass on the leftover subgraph using DFS or strongly connected components.

Watch for contention too. If all workers spend their time fighting over one queue or one hot cache line in the in-degree array, scaling will flatten out quickly. Good graph partitioning and careful data layout matter.

Finally, benchmark against a strong single-threaded baseline. Many real graphs are small enough that the coordination overhead of threads outweighs the parallel speedup.

Summary

  • Parallel DFS for directed cycle detection is difficult because visitation state is shared and order-sensitive.
  • A parallel version of Kahn's algorithm is often a safer way to answer whether a cycle exists.
  • The key shared structures are a concurrent ready queue and atomic in-degree counters.
  • If vertices remain after all zero in-degree work is exhausted, the graph contains a cycle.
  • Use a second pass if you need the actual cycle path rather than a boolean answer.
  • Always compare multithreaded performance with a solid single-threaded implementation.

Course illustration
Course illustration

All Rights Reserved.