跪拜 Guibai
← Back to the summary

Swoole's Coroutine Model Explained: Cooperative Scheduling, Resident-Memory Traps, and How It Compares to Go's goroutines

1. Introduction: Why Coroutines Are Needed

In PHP's traditional execution model, one request corresponds to one process (or one FPM worker). When the request ends, all variables, connections, and memory in the process are released. This "one request, one lifecycle" model is simple and reliable, but it hits two ceilings:

By introducing coroutines (Coroutine), Swoole lets PHP write "synchronous-looking code with asynchronous performance," much like Go. This article walks through the Swoole coroutine model, focusing on three points:

  1. The essential differences between coroutines, threads, and processes;
  2. How Swoole's coroutine scheduling works;
  3. The "pitfalls" of resident memory, and how Swoole coroutines compare with Go goroutines.

2. Coroutines, Threads, Processes: Where the Real Differences Lie

To understand coroutines, first place them in the abstraction hierarchy of "process → thread → coroutine".

Dimension Process Thread Coroutine
Scheduler OS kernel OS kernel User-space program (runtime)
Switch cost Highest (page table switch, TLB flush) High (kernel-mode switch) Extremely low (user-space saves a few registers)
Memory isolation Fully isolated Shares process memory Shares thread memory
Concurrency scale Tens to hundreds Hundreds to thousands Tens of thousands to hundreds of thousands
Preemption Preemptive Preemptive Cooperative (voluntary yield)

In one sentence:

Because coroutine switching happens in user space, each switch only involves saving and restoring a few registers. The cost is roughly one percent of a thread switch or even lower. This is what makes "supporting hundreds of thousands of concurrent connections on a single machine" possible.

Here is a diagram comparing the hierarchy and switch paths of the three:

flowchart LR
  subgraph P["Process (resource isolation)"]
    subgraph T["Thread (CPU scheduling unit)"]
      C1["Coroutine 1"]
      C2["Coroutine 2"]
      C3["Coroutine N"]
    end
  end
  C1 -. "User-space switch, no kernel entry" .-> C2
  C2 -. "User-space switch, no kernel entry" .-> C3

2.1 The Prerequisite for Cooperative Scheduling: Voluntary Yield

Coroutines are cooperative. Unlike threads, they are not preempted by the kernel at arbitrary moments; they switch only when encountering a "yield" operation. In Swoole, these yield points include:

If a piece of coroutine code is pure CPU computation, it will keep occupying the current thread and other coroutines will not get executed. This differs fundamentally from Go's preemptive scheduling, as discussed later.

3. Swoole's Coroutine Scheduling Model

Swoole coroutines run on a single thread inside a Worker process, following the model of "one thread running many coroutines." The overall structure can be understood as:

flowchart TD
  A["Master process"] --> B["Manager process"]
  B --> C["Worker process 1"]
  B --> D["Worker process 2"]
  B --> E["Worker process N"]
  C --> C1["Reactor event loop (single thread)"]
  C1 --> C2["Coroutine 1"]
  C1 --> C3["Coroutine 2"]
  C1 --> C4["Coroutine ..."]

Key points:

The full scheduling sequence for a coroutine during one I/O request:

sequenceDiagram
  participant W as "Worker event loop"
  participant C as "Coroutine A"
  participant IO as "I/O event (MySQL/Redis)"
  W->>C: "Resume execution"
  C->>IO: "Issue async I/O"
  C-->>W: "Suspend and yield control"
  W->>W: "Schedule other ready coroutines"
  IO-->>W: "I/O complete, callback enters ready queue"
  W->>C: "Resume coroutine A"
  C-->>W: "Execution finishes, coroutine exits"

3.1 Coroutine Creation and Lifecycle

<?php
// Create a coroutine
Co\run(function () {
    go(function () {
        Co::sleep(0.1);
        echo "Coroutine A done\n";
    });

    go(function () {
        echo "Coroutine B executes immediately\n";
    });

    echo "Main coroutine continues\n";
});

The execution order is roughly: first "Coroutine B executes immediately," then "Main coroutine continues," and finally "Coroutine A done" after 0.1 seconds. Because coroutine A yields at Co::sleep, the event loop turns to run other ready coroutines.

3.2 Coroutine Context and Coroutine ID

Each coroutine has an independent coroutine context (Context), accessible via Co::getContext(). It is naturally isolated and does not leak between coroutines like global variables:

<?php
Co\run(function () {
    go(function () {
        Co::getContext()['request_id'] = 'req-001';
        Co::sleep(0.01);
        // Here it still reads req-001, not overwritten by other coroutines
        echo Co::getContext()['request_id'], PHP_EOL;
    });

    go(function () {
        Co::getContext()['request_id'] = 'req-002';
        Co::sleep(0.01);
        echo Co::getContext()['request_id'], PHP_EOL;
    });
});

This is extremely important: coroutine context is the correct replacement for "global variables storing request data," and it is one of the core solutions to the resident-memory pitfalls discussed later.

Practical: Simulating request context in HTTP request handling

Here is a close-to-real example simulating two concurrent HTTP requests. Each request coroutine carries its own request_id and user information, and after several I/O yields, it still reads its own data.

Correct approach: using Co::getContext() to store request data

<?php
use Swoole\Coroutine as Co;

function handleRequest(string $requestId, array $user): void
{
    // Put all request-level data into the current coroutine's context
    $ctx = Co::getContext();
    $ctx['request_id'] = $requestId;
    $ctx['user'] = $user;

    // Simulate multiple I/O operations: query user, query orders, write logs
    Co::sleep(0.01);
    $ctx = Co::getContext();
    echo "[{$ctx['request_id']}] User: {$ctx['user']['name']}, start querying orders\n";

    Co::sleep(0.02);
    $ctx = Co::getContext();
    echo "[{$ctx['request_id']}] User: {$ctx['user']['name']}, order query complete\n";
}

Co\run(function () {
    go(fn () => handleRequest('req-001', ['id' => 1, 'name' => 'Alice']));
    go(fn () => handleRequest('req-002', ['id' => 2, 'name' => 'Bob']));
});

Output (order may vary slightly due to scheduling, but each request's data remains consistent):

[req-001] User: Alice, start querying orders
[req-002] User: Bob, start querying orders
[req-001] User: Alice, order query complete
[req-002] User: Bob, order query complete

Wrong approach: using static variables to store request data

<?php
use Swoole\Coroutine as Co;

class RequestContext
{
    // Dangerous: static variables are globally unique in a resident process, shared by all coroutines
    public static string $requestId = '';
    public static array $user = [];
}

function handleRequestBad(string $requestId, array $user): void
{
    RequestContext::$requestId = $requestId;
    RequestContext::$user = $user;

    Co::sleep(0.01); // yield; another coroutine may overwrite the static variables

    echo "[{RequestContext::$requestId}] User: {RequestContext::$user['name']}, start querying orders\n";

    Co::sleep(0.02);

    echo "[{RequestContext::$requestId}] User: {RequestContext::$user['name']}, order query complete\n";
}

Co\run(function () {
    go(fn () => handleRequestBad('req-001', ['id' => 1, 'name' => 'Alice']));
    go(fn () => handleRequestBad('req-002', ['id' => 2, 'name' => 'Bob']));
});

Output (data leak; both requests see the later-written Bob):

[req-002] User: Bob, start querying orders
[req-002] User: Bob, start querying orders
[req-002] User: Bob, order query complete
[req-002] User: Bob, order query complete

Comparison conclusion

4. Resident Memory: Power and Pitfalls

After a Swoole service starts, Worker processes stay resident in memory. They are not destroyed after each request like FPM. This brings huge performance gains but also many pitfalls.

Under resident memory, the relationship between requests and Worker lifecycle differs completely from FPM:

flowchart TD
  subgraph FPM["FPM mode"]
    F1["Request arrives"] --> F2["Create/reuse process"]
    F2 --> F3["Process request"]
    F3 --> F4["Request ends, memory fully released"]
  end
  subgraph SW["Swoole resident mode"]
    S1["Service starts"] --> S2["Worker resident memory"]
    S2 --> S3["Request 1 processing"]
    S3 --> S4["Request 2 processing"]
    S4 --> S5["Request N processing"]
    S5 --> S6["Memory accumulates, needs active management"]
  end

4.1 The Benefits: Connection Reuse and Resident State

4.2 Pitfall One: Global and Static Variables Leak Across Requests

This is the most common and most subtle pitfall. Under FPM, static variables and global variables are destroyed when the request ends; under Swoole's resident process, they stay alive.

<?php
// Wrong: static variables accumulate across requests in a resident process
class Counter
{
    public static int $count = 0;

    public static function incr(): int
    {
        return ++self::$count;
    }
}

If every request calls Counter::incr(), the return value keeps increasing, not starting from 1 each time. Correct practice: put request-level data in coroutine context, or explicitly reset variables per request.

4.3 Pitfall Two: Memory Leaks Amplified

Under FPM, even if a slight memory leak exists, the process is reclaimed after the request ends and the problem is not obvious. Under a resident process, leaks accumulate linearly with request volume, eventually causing OOM.

Common leak sources:

Diagnosis: periodically print memory_get_usage(true) and check whether it rises monotonically with request count.

Practical: Locating and diagnosing coroutine leaks

Among memory leaks, one type is especially subtle: coroutine leaks. A coroutine is created but never exits; the stack, context, connections, and large objects it holds stay in memory. memory_get_usage(true) rises monotonically with request count, but conventional memory diagnostics struggle to identify who did not exit.

Diagnosing coroutine leaks revolves around two things: first watch the count trend, then capture the call stack.

Step 1: Use Coroutine::stats() to observe coroutine count growing with requests

Swoole\Coroutine::stats() returns statistics for the current Worker's coroutines; coroutine_num is the current number of live coroutines. In a healthy service it should fluctuate within a stable range (roughly equal to concurrent requests). If it keeps rising with request count and does not fall back after requests finish, a coroutine leak is basically confirmed.

<?php
use Swoole\Coroutine as Co;
use Swoole\Coroutine\Channel;

// Simulate a "leaky" coroutine: pops from a Channel without timeout, waiting forever
function leakyTask(Channel $ch): void
{
    // Dangerous: pop with no timeout; the Channel never gets data pushed, so the coroutine hangs forever
    $data = $ch->pop();
    echo "Got data: {$data}\n";
}

// Simulate a "normal" coroutine: has timeout, exits voluntarily after timeout
function normalTask(Channel $ch): void
{
    $data = $ch->pop(0.1); // 0.1s timeout; returns false if no data, then exits
    echo $data === false ? "Normal coroutine timed out and exited\n" : "Got data: {$data}\n";
}

Co\run(function () {
    $ch = new Channel(1);

    // Each round simulates a batch of requests; observe how coroutine count changes
    for ($round = 1; $round <= 5; $round++) {
        for ($i = 0; $i < 10; $i++) {
            go(fn () => leakyTask($ch));   // leak source
            go(fn () => normalTask($ch));  // control group
        }

        // Give normal coroutines time to time out and exit
        Co::sleep(0.2);

        $stats = Co::stats();
        echo "Round {$round}: current live coroutines = {$stats['coroutine_num']}\n";
    }
});

Output approximates (normal coroutines exit each round; leaky coroutines accumulate 10 per round):

Normal coroutine timed out and exited
Normal coroutine timed out and exited
...
Round 1: current live coroutines = 10
Round 2: current live coroutines = 20
Round 3: current live coroutines = 30
Round 4: current live coroutines = 40
Round 5: current live coroutines = 50

coroutine_num increasing by exactly +10 each round is the clearest signal of a leak. In production, this value can be sent to monitoring and used with alert thresholds.

Step 2: Use Co::getCid() and Co::getBackTrace() to print call stacks and find which coroutine did not exit

Knowing "50 coroutines did not exit" is not enough; you need the exact line they are stuck on. The idea: record the cid when a coroutine is created, and when diagnosing, print call stacks for each live cid.

<?php
use Swoole\Coroutine as Co;
use Swoole\Coroutine\Channel;

// Record all created coroutine IDs
$cids = [];

function trackedTask(Channel $ch): void
{
    global $cids;
    $cid = Co::getCid();   // current coroutine ID
    $cids[$cid] = true;

    $data = $ch->pop();    // leak point: no timeout
    echo "Got data: {$data}\n";

    unset($cids[$cid]);    // remove on normal exit
}

Co\run(function () {
    global $cids;
    $ch = new Channel(1);

    for ($i = 0; $i < 5; $i++) {
        go(fn () => trackedTask($ch));
    }

    Co::sleep(0.2);

    $stats = Co::stats();
    echo "Current live coroutines: {$stats['coroutine_num']}\n";

    // Print call stack for each still-live coroutine
    foreach (array_keys($cids) as $cid) {
        echo "---- Call stack for coroutine {$cid} ----\n";
        // getBackTrace needs the target coroutine ID and returns the coroutine's current call stack array
        $trace = Co::getBackTrace($cid);
        foreach ($trace as $frame) {
            $file = $frame['file'] ?? 'unknown';
            $line = $frame['line'] ?? 0;
            $func = $frame['function'] ?? 'unknown';
            echo "  {$file}:{$line}  {$func}()\n";
        }
    }
});

The output points directly to the line where each leaky coroutine is stuck: trackedTask()'s $ch->pop(). The problem is obvious at a glance. In production, this logic can be turned into a scheduled task or debugging endpoint that triggers a snapshot when memory alerts fire.

Common coroutine leak scenarios and fixes

Scenario Leak cause Fix
Channel pop without timeout Producer abnormal or not started; consumer coroutine hangs forever pop($timeout) with timeout; return or retry after timeout
Infinite loop inside coroutine while (true) has no yield point or exit condition Add exit condition; use Co::sleep() to yield inside loop; or control lifecycle with Channel
Uncaught exception suspends coroutine Exception thrown inside coroutine not caught; subsequent logic never runs; resources not released Wrap coroutine entry in try/catch/finally; release resources in finally
go() created but nobody waits Parent coroutine does not care about child result; child stuck unnoticed Use WaitGroup to wait, or record cid for timeout monitoring

Example fixes:

<?php
use Swoole\Coroutine as Co;
use Swoole\Coroutine\Channel;
use Swoole\Coroutine\WaitGroup;

Co\run(function () {
    $ch = new Channel(1);
    $wg = new WaitGroup();

    // Fix 1: set timeout on pop to avoid permanent hang
    $wg->add();
    go(function () use ($ch, $wg) {
        try {
            $data = $ch->pop(1.0); // 1 second timeout
            if ($data === false) {
                echo "Timeout waiting, coroutine exits voluntarily\n";
                return;
            }
            echo "Got data: {$data}\n";
        } catch (\Throwable $e) {
            // Fix 3: catch exception to avoid coroutine interruption from uncaught exception
            echo "Coroutine exception: {$e->getMessage()}\n";
        } finally {
            $wg->done(); // notify WaitGroup regardless of success or failure
        }
    });

    // Fix 2: infinite loop gets exit condition and yield point
    $wg->add();
    go(function () use ($wg) {
        $running = true;
        while ($running) {
            // business logic
            Co::sleep(0.01); // yield voluntarily to avoid blocking the event loop
            $running = false; // exit when condition met
        }
        $wg->done();
    });

    // Fix 4: use WaitGroup to wait for all child coroutines to finish, avoiding "created but nobody manages"
    $wg->wait();
    echo "All coroutines exited, current live count: " . Co::stats()['coroutine_num'] . "\n";
});

Here is a flowchart tying together the entire diagnostic decision path, from monitoring to locating the blocking point to fixing:

flowchart TD
  A["Monitoring shows coroutine_num rising monotonically"] --> B["Record cid snapshot (register at creation, remove at exit)"]
  B --> C["Use Co::getBackTrace(cid) to print call stack"]
  C --> D{"Locate blocking point"}
  D --> E["Channel pop without timeout"]
  D --> F["Infinite loop inside coroutine"]
  D --> G["Uncaught exception causing hang"]
  D --> H["go() without anyone waiting"]
  E --> E1["pop(timeout) with timeout; return or retry after timeout"]
  F --> F1["Add exit condition; use Co::sleep() to yield inside loop"]
  G --> G1["Wrap entry in try/catch/finally; release in finally"]
  H --> H1["Use WaitGroup to wait, or record cid for timeout monitoring"]

This diagram breaks diagnosis into "discover → capture scene → locate → fix": first use Co::stats()['coroutine_num'] to judge whether a leak exists, then record cid and use Co::getBackTrace() to capture the call stack, find the exact line stuck, and finally apply the matching fix. The core principle: every blocking call must have a timeout, every coroutine entry must have try/catch/finally, and every go() must be waited on or monitored.

Diagnosis summary

4.4 Pitfall Three: Residual State in Connections and Resources

A resident connection may have entered an abnormal state in a previous request (e.g., uncommitted transaction, connection dropped by the server). Reusing it in the next request can blow up. Recommendations:

Practical: Coroutine MySQL connection pool with health check on borrow

Below is a minimal runnable example demonstrating pool creation, health check on borrow (ping failure rebuilds), and return after the request. It uses Swoole\Coroutine\Channel to hand-write a lightweight pool so every step is visible.

<?php
use Swoole\Coroutine as Co;
use Swoole\Coroutine\Channel;
use Swoole\Coroutine\MySQL;

class MySQLPool
{
    private Channel $pool;
    private array $config;

    public function __construct(array $config, int $size = 10)
    {
        $this->config = $config;
        // Channel acts as the pool container; capacity equals max connections
        $this->pool = new Channel($size);
        for ($i = 0; $i < $size; $i++) {
            $conn = $this->createConnection();
            if ($conn !== null) {
                $this->pool->push($conn);
            }
        }
    }

    private function createConnection(): ?MySQL
    {
        $conn = new MySQL();
        $ok = $conn->connect([
            'host'     => $this->config['host'],
            'port'     => $this->config['port'],
            'user'     => $this->config['user'],
            'password' => $this->config['password'],
            'database' => $this->config['database'],
            'timeout'  => 1,
        ]);
        return $ok ? $conn : null;
    }

    /**
     * Borrow a connection: must do health check.
     * In a resident process, the server may have closed the connection (wait_timeout, network jitter, master-slave switch).
     * Reusing it directly throws "MySQL server has gone away," so ping first.
     */
    public function get(float $timeout = 1.0): ?MySQL
    {
        $conn = $this->pool->pop($timeout);
        if ($conn === false) {
            return null; // pool empty and timeout
        }

        // ping failure means the connection is invalid; discard and rebuild
        if (!$conn->connected || !$conn->query('SELECT 1')) {
            $conn->close();
            $conn = $this->createConnection();
        }

        return $conn;
    }

    /**
     * Return a connection: must be returned at request end, otherwise the pool is exhausted.
     */
    public function put(?MySQL $conn): void
    {
        if ($conn === null) {
            return;
        }
        // Confirm the connection is still usable before returning; drop bad ones to avoid polluting the pool
        if ($conn->connected && $conn->query('SELECT 1')) {
            $this->pool->push($conn);
        } else {
            $conn->close();
            $new = $this->createConnection();
            if ($new !== null) {
                $this->pool->push($new);
            }
        }
    }
}

// Usage: simulate a request coroutine
$pool = new MySQLPool([
    'host'     => '127.0.0.1',
    'port'     => 3306,
    'user'     => 'root',
    'password' => '123456',
    'database' => 'test',
], 10);

Co\run(function () use ($pool) {
    $conn = $pool->get();
    if ($conn === null) {
        echo "Failed to get connection\n";
        return;
    }

    try {
        $rows = $conn->query('SELECT * FROM users LIMIT 1');
        echo "Query rows: " . count($rows ?: []) . "\n";
    } finally {
        // Return regardless of success or failure to prevent connection leak
        $pool->put($conn);
    }
});

Why health checks are mandatory in a resident process?

4.5 Pitfall Four: Shared State Races Between Coroutines

Although coroutines are scheduled on a single thread and there is no true "simultaneous write," interleaved execution can still cause logic errors. For example, two coroutines both perform a "read-modify-write":

<?php
Co\run(function () {
    $shared = 0;

    go(function () use (&$shared) {
        $tmp = $shared;
        Co::sleep(0.01); // yield; another coroutine may interleave
        $shared = $tmp + 1;
    });

    go(function () use (&$shared) {
        $tmp = $shared;
        Co::sleep(0.01);
        $shared = $tmp + 1;
    });
});

Finally, $shared may be 1 instead of 2. The solution is to use Swoole\Coroutine\Channel or Swoole\Lock for synchronization, or avoid shared mutable state altogether.

A diagram showing how interleaved "read-modify-write" causes deviation:

sequenceDiagram
  participant A as "Coroutine A"
  participant B as "Coroutine B"
  participant S as "Shared variable shared"
  A->>S: "read shared = 0"
  A-->>B: "Co::sleep yield"
  B->>S: "read shared = 0"
  B->>S: "write shared = 1"
  A->>S: "write shared = 1 (overwrite)"
  Note over S: "final result 1, not expected 2"

5. Swoole Coroutines vs Go Goroutines

Both are user-space lightweight execution flows, but their design trade-offs differ. A comparison across key dimensions:

Dimension Swoole Coroutine Go goroutine
Scheduling Cooperative, yields on I/O Preemptive (Go 1.14+ signal-based async preemption)
Execution carrier Single-threaded event loop GMP model, multi-threaded parallelism
Parallelism No parallelism within a single Worker; scales via multiple processes Native multi-core parallelism
Stack size Fixed stack (stack_size configurable, default 2MB) Dynamically sized stack, starts around 2KB
Communication Channel, coroutine context Channel, sync package
CPU-intensive tasks Blocks the entire Worker Can be preempted; impact smaller

5.1 Scheduling: Cooperative vs Preemptive

Swoole coroutines are cooperative. An infinite loop can stall the entire Worker:

<?php
Co\run(function () {
    go(function () {
        while (true) {
            // pure CPU computation, no yield point
        }
    });

    go(function () {
        echo "This coroutine will never be executed\n";
    });
});

Go introduced signal-based async preemption in 1.14. Long-running goroutines can be forcibly interrupted, so the above scenario does not starve other goroutines in Go. Note that preemption only happens near safe points such as function calls, and Go 1.14's preemption capability was still limited, improving only in later versions. Practical advice: in Swoole, avoid long-running CPU-intensive computation inside coroutines. When necessary, split it into small pieces and voluntarily Co::sleep(0) to yield, or hand it to a separate process.

5.2 Parallelism: Single Thread vs Multi-Core

Swoole coroutines run on a single-threaded event loop. Coroutines within the same Worker do not truly execute in parallel. Multi-core utilization requires opening multiple Worker processes. Go's GMP model distributes goroutines across multiple P (logical processors), executed in parallel by multiple M (system threads).

This means:

The parallelism models differ as follows:

flowchart TD
  subgraph Swoole["Swoole: single-thread event loop + multiple processes"]
    SW1["Worker process 1 (single thread)"] --> SW2["Coroutines execute serially, no parallelism"]
    SW3["Worker process 2 (single thread)"] --> SW4["Coroutines execute serially, no parallelism"]
    SW1 -. "multi-process for multi-core" .- SW3
  end
  subgraph Go["Go: GMP multi-thread parallelism"]
    G1["P1"] --> G2["M1 system thread"]
    G3["P2"] --> G4["M2 system thread"]
    G2 --> G5["goroutines execute in parallel"]
    G4 --> G5
  end

5.3 Stack and Memory Usage

Swoole coroutines use a fixed-size stack (configured by stack_size, default 2MB). Creation cost is low but inflexible; deep recursion or large local variables can overflow the stack. Go goroutines have dynamically growing stacks, initially about 2KB, growing and shrinking on demand, better suited for recursion of uncertain depth.

5.4 Mental Model Differences

6. Best Practice Checklist

With the above analysis, here are several practical recommendations:

  1. Put request-level data in coroutine context, not in global or static variables;
  2. Avoid long-running CPU computation inside coroutines; split and yield, or delegate to separate processes;
  3. Use connection pools with health checks to avoid reusing broken connections;
  4. Monitor the memory curve; resident-process memory leaks must be caught early;
  5. Lock shared mutable state or use Channel; never assume single-threaded means no races;
  6. Know the boundaries of cooperative scheduling; any synchronous blocking function is a potential single point of failure.

7. Summary

Swoole's coroutine model is essentially "single-threaded event loop + user-space cooperative scheduling." It trades extremely low switching cost for high-concurrency I/O capability, but it demands developers stay vigilant about resident memory and blocking operations.

Compared with Go goroutines:

Understanding these differences is the key to avoiding resident-memory pitfalls in selection and coding, and to truly unleashing the power of coroutines.