Multithreading ᴾᴴᴾ - chung-leong/zigar GitHub Wiki

JavaScript | PHP


PHP is a single-threaded language. While some form of cooperative multitasking has been possible since the introduction of fibers in version 8.1, fundamentally, things do not happen concurrently in a PHP application. Things happen one after another. Only the main thread can make changes to the program state.

What happens then, when a new thread spawn by Zig code calls a PHP function or writes to a PHP stream? The answer is: the request goes into an event queue. The calling thread then waits for the main thread to fetch the event and process it. At what time this happens is up to the PHP code. It could be never, if your code makes no effort at actually processing the events as in the following example:

const std = @import("std");

const zigar = @import("zigar");

const Callback = fn () void;

pub fn call(cb: *const Callback) !void {
    const thread = try std.Thread.spawn(.{}, run, .{cb});
    thread.detach();
}

fn run(cb: *const Callback) !void {
    std.debug.print("In new thread\n", .{});
    cb();
}

pub fn startup() !void {
    try zigar.thread.use();
}

pub fn shutdown() void {
    zigar.thread.end();
}
<?php

$m = zigar_use(__DIR__ . '/multithread-example-1.zig');

$m->startup();
try {
    $m->call(function() {
        echo "Callback invoked\n";
    });
    sleep(1);
} finally {
    $m->shutdown();
}

The code above does nothing aside from sleeping a second. It doesn't print the message given to std.debug.print() nor does it invoke the callback. While event handling does get activated through zigar.thread.use(), there's no loop that actually handles the events. The script terminates with them still sitting in the queue.

An event loop must be present for multithreaded code to work. Unlike Node.js, PHP has no built-in event loop. Zigar does provide a default implementation with limited capability. You can also use Revolt, widely regarded as the defacto standard.

Using the temporary event loop

The temporary event loop is designed to enable the use of async Zig functions in traditional non-async PHP programming. It becomes active whenever your code is waiting for a promise to resolve or a generator to generate something.

The following code provides a proper async function:

const std = @import("std");

const zigar = @import("zigar");

const Callback = fn () void;

pub fn call(cb: *const Callback, promise: zigar.function.Promise(bool)) !void {
    const thread = try std.Thread.spawn(.{}, run, .{ cb, promise });
    thread.detach();
}

fn run(cb: *const Callback, promise: zigar.function.Promise(bool)) !void {
    std.debug.print("In new thread\n", .{});
    cb();
    promise.resolve(true);
    std.debug.print("Promise resolved\n", .{});
}

pub fn startup() !void {
    try zigar.thread.use();
}

pub fn shutdown() void {
    zigar.thread.end();
}
<?php

$m = zigar_use(__DIR__ . '/multithread-example-2.zig');

$m->startup();
try {
    $m->call(function() {
        echo "Callback invoked\n";
    });
} finally {
    $m->shutdown();
}
In new thread
Callback invoked

When the function call gets called, Zigar automatically provides a Promise object. After calling it, Zigar suspends the current fiber by switching to another fiber, one that continually reads from the event queue and dispatches them.

In the example, the loop will first see a system-call request for writing to stderr. It then receives a PHP-call request--invocation of cb. Finally, it receives second a PHP-call request--invocation of the promise's callback function. The callback tells PHP to switch back to the original fiber and the async call is complete. At this point the event loop is no longer active. That's why there's no output from the second call to std.debug.print().

Using Revolt

The following shows the same example with a proper event loop:

<?php

require __DIR__ . '/vendor/autoload.php';

use Revolt\EventLoop;

ini_set('zigar.event_loop', 'revolt');

EventLoop::defer(function() {
    $m = zigar_use(__DIR__ . '/multithread-example-2.zig');
    $m->startup();
    EventLoop::onReadable(STDIN, function ($callbackId, $strm) use($m) {
        $cmd = trim(fgets($strm));
        if ($cmd === 'exit') {
            $m->shutdown();
            EventLoop::cancel($callbackId);
        }
    });
    $m->call(function() {
        echo "Callback invoked\n";
    });
});
EventLoop::run();
In new thread
Callback invoked
Promise resolved

Instead of shutting down immediately after the call, here we choose to exit only after the command is given. This is more typical of how async programming is done.

Using a work queue

If your goal is simply to off-load time-consuming processes to threads, Zigar's WorkQueue let you accomplish this with little efforts:

const std = @import("std");

const zigar = @import("zigar");

var work_queue: zigar.thread.WorkQueue(worker) = .{};

pub const getBeer = work_queue.promisify(worker.getBeer);
pub const getBeers = work_queue.asyncify(worker.getBeers);
pub const startup = work_queue.promisify(.startup);
pub const shutdown = work_queue.promisify(.shutdown);

const worker = struct {
    pub fn getBeer(allocator: std.mem.Allocator) ![]const u8 {
        std.Thread.sleep(100_000_000);
        return try allocator.dupe(u8, "Tatra mocne");
    }

    pub fn getBeers() BeerIterator {
        return .{};
    }

    const BeerIterator = struct {
        index: usize = 0,

        const list = [_][]const u8{ "Tyskie", "Żywiec", "Lech", "Okocim" };

        pub fn next(self: *@This(), allocator: std.mem.Allocator) !?[]const u8 {
            if (self.index >= list.len) return null;
            defer self.index += 1;
            std.Thread.sleep(500_000_000);
            return try allocator.dupe(u8, list[self.index]);
        }
    };
};
<?php

$m = zigar_use(__DIR__ . '/multithread-example-3.zig');

$m->startup(4);
try {
    echo $m->getBeer(), "\n";
    echo "More beers:\n";
    foreach($m->getBeers() as $beer) {
        echo "$beer\n";
    }
} finally {
    $m->shutdown();
}
Tatra mocne
More beers:
Tyskie
Żywiec
Lech
Okocim

A work queue's promisify method takes a regular function and turns it into an async function. Its asyncify method is a more generalized version that creates a function returning an async generator when given a function whose return value is an iterator.

When given the special enum literals .startup or .shutdown, promisify() returns a function that starts up or shuts down the work queue.


Promise | Generator