php: concurrency with processes. pt. 1: using pcntl_fork for fun and performance
i often joke that php is a systems programming language that you can also use to create a home page. it’s not really true, of course; no one is going to write, say, a video driver in php. but it does highlight the fact that php has an absolute tonne of functionality that is either straight through calls to c or built-in libraries that allow us to access all sorts of operating systems features.
in this series, we’re going to leverage some of that power to write a php program that does multiple things at the same time. we’ll do that by using pcntl_fork
to create a number of child processes that run simultaneously. then in part two, we’ll be looking at shmop, php’s ‘shared memory operations’, as a way to allow those processes to communicate their results.
a process forking a copy of itself to do work concurrently
a very short and extremely optional explanation of fork
if you are already familiar with processes and forking on linux-like systems you can skip this part. if you aren’t and you’ve made it this far, then you deserve an explanation. we’ll keep it short.
on your linux or linux-like system, every program that is running is referred to as a ‘process’, and every process has a unique id number assigned to it by the operating system, called a ‘process id’ or PID
. if we want to see the processes we are currently running, we can do that with the ps
command like so.
$ ps
PID TTY TIME CMD
689209 pts/9 00:00:01 fish
701503 pts/9 00:00:00 ps
the output here shows that we are running two processes: the fish shell (an alternative to bash) and the ps
command itself. we can also see the PID
s of both of those processes. if we want to see a list of all the processes running on our system, including everything from the operating system, we can run ps -e
. there will be a lot of output.
we can get details on an individual process by passing the PID
to ps
. for instance, to look at our fish shell process, which in this example is PID
689209, we could run:
$ ps -f 689209
UID PID PPID C STIME TTY STAT TIME CMD
ghorwood 689209 4722 0 11:58 pts/9 Ssl 0:02 /usr/bin/fish
the interesting part of this output is the PPID
column. this is the “parent” process id, and this is where we start talking about fork.
every process running was, at some point, launched by another process. when we ran ps
, for instance, what happened under the hood is that the fish process launched the ps
process for us. this makes fish the “parent” of ps
.
this is called fork-ing. technically, when fork is first called, a clone of the parent process is started, and it is this clone that then runs the code we want, ps
in our example.
we can see this fork relationship by running the pstree
command for our fish process.
pstree -s -p 689209
systemd(1)───systemd(3450)───gnome-terminal-(4722)───fish(689209)─┬─pstree(701754)
├─{fish}(701752)
└─{fish}(701753)
this output shows all the parent and child processes of our fish shell (PID
689209). the parents are systemd (PID
1) forking another, user-space, systemd which in turn forks gnome-terminal which forks fish. we can also see the child pstree
process which was forked by fish.
in this post we will be writing a php script that forks a number of children that will run as separate processes and do work. since all of those children are their own processes, they will all run simultaneously. when each child process has completed, our parent php script will resume running.
is this even remotely a good idea?
whether using fork and shared memory as a concurrency strategy is a good idea or not is… debatable.
on one hand, all the commands to do this are part of php and have been for decades. pcntl_fork
debuted in version 4.1.0 (by comparison, all those json functions we use every day didn’t show up until 5-something), so it’s not like this is some untested, bleeding edge stuff. if anything, it’s a bit stodgy and old school. and, of course, it works. that’s a big plus.
there are drawbacks, however. if a developer other than you winds up having to maintain your code, there’s a near one hundred percent chance that they will have no idea what’s going on. and, of course, there’s the issue of windows. some of the features we will be using (ie. ftok, which we will use for shared memory) aren’t available on windows. of course, if you’re using windows to host your php application, you have other problems to deal with.
ultimately, the decision is situational.
a basic fork
we’ll start with the basics: a script that forks one child process. to do the forking itself, we’ll use the php command pcntl_fork
.
let’s look at the code and what it outputs first and then we’ll go over what’s happening and why.
echo "start script with pid ".getmypid().PHP_EOL;
$pid = pcntl_fork();
echo "pcntl_fork has been called".PHP_EOL;
if($pid == -1) {
echo "this is an error".PHP_EOL;
}
else if($pid == 0) {
echo "run child block with pid ".getmypid().PHP_EOL;
}
else {
echo "run parent block with pid ".getmypid().PHP_EOL;
}
this outputs something similar to:
start script with pid 442811
pcntl_fork has been called
run parent block with pid 442811
pcntl_fork has been called
run child block with pid 442812
the first thing we notice is a call to getmypid. as the name makes abundantly clear, this function returns the id of the current running process. this is extremely useful. here, we’re echoing it at the start of the script so we know what our starting PID
is before we go and do things like fork other processes.
we call pcntl_fork
on line 3. this is where the real action starts.
when we do this, a new child process is immediately launched and runs concurrently with the parent process. there are some important things to note about pcntl_fork
:
the child process code is an exact copy of the parent: when we fork a process, we create an exact clone of the code that forked it.
the child process starts execution immediately after
pcntl_fork
: the first line our child process runs is the line immediately after the fork call. if we look at the output of our script, for example, we see that the line “start script with pid” is printed only once. this is because that echo statement was made beforepcntl_fork
and, thus, is only run by the parent. by comparison, the command that prints out “pcntl_fork has been called” runs twice, once in the parent process and once in the child, because it occurs afterpcntl_fork
.the only way to know if the code running is a child or the parent is by the
PID
: we will probably want our child process to do something different than our parent. since the code the child process runs is the exact same as the parent, however, we need to build in our script a way to determine if we are in the child or parent process. we do this by testing thePID
thatpcntl_fork
returns. there are three potential values for thisPID
:0
: we are in the child process. we can get the exactPID
, if we need it, with getmypid.any value greater than 0: we are in the parent process.
-1
: an error has ocurred. if we look at the example code, we can see that we use this return value to have three if blocks of code: one that runs in the parent process, one that runs in the child, and one that runs if everything goes off the rails.
all variables are available to the child process. kind of: the child process we fork is an exact clone of the parent process that created it. this includes all of the variables used. our child process can read those variables and set them to new values and even define new variables, however these changes are limited to the child process’ ‘scope’. variables set in the child are unavailable to the parent.
a developer observing that the child and parent processes are the same script.
a short look at memory and ‘scope’
that last point, the one about variable access, deserves a bit more attention.
every process on your linux-like system has it’s own block of memory. when we fork a child process, we get a copy of the parent’s memory block. that includes all the variables the parent created. we can read and change those variables in our child process, but even though they look like the same variables in our source code, they are in fact different variables living in different memory. let’s look at a demonstration:
echo "parent pid: ".getmypid().PHP_EOL;
$toplevelvar = "foo";
echo getmypid()." ".__LINE__." ".$toplevelvar.PHP_EOL;
$pid = pcntl_fork();
if($pid == -1) {
}
// the child process
else if($pid == 0) {
echo "child pid: ".getmypid().PHP_EOL;
echo getmypid()." ".__LINE__." ".$toplevelvar.PHP_EOL;
$toplevelvar = "bar";
echo getmypid()." ".__LINE__." ".$toplevelvar.PHP_EOL;
}
// the parent process
else {
echo getmypid()." ".__LINE__." ".$toplevelvar.PHP_EOL;
}
echo getmypid()." ".__LINE__." ".$toplevelvar.PHP_EOL;
if we run this example, we will see that the variable $toplevelvar is accessible in both the parent and child processes but that the value change made in the child is not accessible in the parent. the output looks like this:
parent pid: 869430
869430 5 foo
869430 25 foo
869430 28 foo
child pid: 869431
869431 16 foo
869431 20 bar
869431 28 bar
the child process changes the value of $toplevelvar, but to the parent it remains the same. when we get to line 28, we see that the value in the child is ‘bar’, but for the parent it remains ‘foo’.
this, of course, brings up the issue of how we get values from a child process back to its parent. if we spawn a child to do some work for us, we would certainly like for the parent to be able to get the results of that work. we will cover how to do that by using shared memory in the next installment of this series.
forking a bunch of processes in a loop (and managing them)
forking one process is fine, useful even. but what we probably want to do is fork an arbitrary number of children to do an arbitrary number of tasks: download all thirty of those pdfs from the remote server at the same time, for instance, or send three different emails simultaneously.
doing this is almost as straightforward as putting our pcntl_fork
call in a loop. almost. there are few other things we will need to do to manage our child processes and keep everything orderly. let’s look at an example:
// output pid of parent process
echo "start script with pid ".getmypid().PHP_EOL;
// time stamp for timing duration
$start_time = hrtime(true);
for($i = 0; $i < 4; $i++) {
// create the fork
$pid = pcntl_fork();
// an error has ocurred
if($pid === -1) {
echo "error".PHP_EOL;
}
// child process
else if(!$pid) {
echo " child $i with pid ".getmypid().PHP_EOL;
// sleep for five seconds
sleep(5);
// terminate the child process
exit(0);
}
}
// wait for all child processes to finish
while(($pid = pcntl_waitpid(0, $status)) != -1) {
echo "pid $pid finished".PHP_EOL;
}
// output pid of parent process
echo "END script with pid ".getmypid().PHP_EOL;
// output how long the script took
echo "duration: ".(hrtime(true) - $start_time) / 1000000000 . " seconds";
at it’s heart, the functionality of this script is pretty straightforward: we’re forking four child processes that sleep for five seconds (to simulate a time-intensive workload) in a for loop. if we run this script, it’s total execution time is about five seconds, demonstrating that all those child processes are running concurrently. the output looks similar to this:
start script with pid 118298
child 0 with pid 118299
child 1 with pid 118300
child 2 with pid 118301
child 3 with pid 118302
pid 118299 finished
pid 118300 finished
pid 118301 finished
pid 118302 finished
END script with pid 118298
duration: 5.011056452 seconds
however, there are three new things here: the lack of a parent else block in our if statement, the call to exit in the child block, and that whole pcntl_waitpid
thing. we’ll go over all of those.
exiting the child process
when we fork a new process, execution starts on the first line after the call to pcntl_fork
and runs until it terminates. normally, this is the end of the script file. this means that our child processes will run all our code right to the bottom. that’s probably not what we want.
the solution, of course, is to terminate the child process at the end of the else if block. this way, only the code we explicitly write for our child process gets executed by our child process. everything else is run by the parent.
in the example, we’re using exit to terminate the process and passing it an integer as an exit code. in the linux world (where we hopefully live), an exit code of 0 denotes success. every other integer is some sort of error. if you’ve written a lot of bash scripts in your life, you may be familiar with exit codes.
there are a few pre-defined error codes we can use but, generally-speaking, we get to assign our own codes and their meanings.
waiting for children to exit with pcntl_waitpid
a common pattern is to fork a number of processes to do various things and then continue on with the parent process after the children have completed their work and exited. this, of course, raises the question “how does the parent process know when all the child processes have ended”? the answer is pcntl_waitpid
.
when we call pcntl_waitpid
with the PID
of a process, execution is blocked until that child process finishes. the PID
of the child process is then returned.
this basic behaviour isn’t particularly useful, though. it requires us to know the PID
s of our child processes in advance and to wait separately for each one to terminate. fortunately, we can construct our pcntl_waitpid
call to wait for any child process in the group of processes forked by the parent and then test if pcntl_waitpid
returns an error because there are no more children running. if we put that in a while loop, we can pause our script until all the child processes we forked exit.
let’s look at that loop again:
// wait for all child processes to finish
while(($pid = pcntl_waitpid(0, $status)) != -1) {
echo "pid $pid finished".PHP_EOL;
}
here, we are calling pcntl_waitpid
and passing the first argument, the pid we’re waiting on, as 0. using 0 tells pcntl_waitpid
to wait for all the processes in the ‘process group’ owned by the parent; basically, all the processes we forked. there are several other ‘magic’ values we can pass as the pid argument to pcntl_waitpid
, and they’re covered in the official docs, but most of the time, we will want to use 0.
the second argument to pcntl_waitpid
is $status
. this argument is passed by reference, which means we do not assign a value to it before calling the function, and can get the exit code of the child process from it after the call. if we want, we can use this argument to do some error handling.
while(($pid = pcntl_waitpid(0, $status)) != -1) {
if($status != 0) {
echo "pid $pid errored with status ".pcntl_wexitstatus($status).PHP_EOL;
}
else {
echo "pid $pid finished successfully with status $status".PHP_EOL;
}
}
in the above code, if any of our processes exit with any value other than 0 (the default success exit code), we can tell by testing the value of $status
.
a short note about pcntl_wexitstatus
: an annoying thing about process control is that if we terminate a process with, say, exit(1), the value pcntl_waitpid
sets for the status is… not 1. this is due to the behaviour of the underlying operating system. basically, a whole bunch of data on the process, including the number we passed to exit, is combined into one number, and it’s this number that pcntl_waitpid
sets. if we want to extract the exit code our script set, we need to pass the value of $status
to pcntl_wexitstatus
.
a parent process with a child in a failed state
ignoring the parent block
lastly, let’s look at our parent block, the code that runs when pcntl_fork
returns any PID
greater than zero. it’s not there.
since we are calling pcntl_fork
inside of a loop, that means that every time we fork a new process, the parent block will run. in our example, that’s four times. we probably don’t want that.
the solution is to just not include a parent block in our if statement and, instead, consign all parent functionality to either before we call pcntl_fork
or after our child block exits.
a more-meaningful example
all of this might seem a little academic, so let’s look at something that at least approximates a real-world example.
our task is to download a number (three, to be precise) of large pdfs from a remote server. doing this sequentially takes a long time; the pdfs are big, after all. we can solve this by forking one child process per pdf we need to download so that the total execution time will only be as long as it takes for the largest pdf to download.
// an array of files to download
$urls = [
'https://example.ca/dndcharacter.pdf',
'https://example.ca/songlyrics.pdf',
'https://example.ca/nonexistantfile.pdf', // this file doesn't exist
];
for($i = 0; $i < count($urls); $i++) {
// create the fork
$pid = pcntl_fork();
// an error has ocurred
if($pid === -1) {
echo "error".PHP_EOL;
}
// child process
else if(!$pid) {
// copy remote file to /tmp. handle errors.
if(!@copy($urls[$i], "/tmp/".basename($urls[$i]))) {
echo error_get_last()["message"].PHP_EOL;
exit(1);
}
// a success message
echo "copied ${urls[$i]} to /tmp/".basename($urls[$i]).PHP_EOL;
exit(0);
}
}
// wait for all child processes to finish
while(($pid = pcntl_waitpid(0, $status)) != -1) {
// handle error statuses
if($status != 0) {
echo "pid $pid errored with status ".pcntl_wexitstatus($status).PHP_EOL;
}
}
there is nothing new, here. this is just an implementation of all the stuff we’ve gone over so far. we take an array of urls of our pdfs and loop over it, forking a process to download each file concurrently using copy.
when we look at the output, it’s what we expect. our nonexistent file errors and the rest of the files get downloaded to /tmp.
pid 240494 errored with status 1
copied https://example.ca/dndcharacter.pdf to /tmp/dndcharacter.pdf
copied https://example.ca/songlyrics.pdf to /tmp/songlyrics.pdf
we can now fork child processes. that’s half the battle!
we now have everything we need to be able to fork an arbitrary number of child processes to do work for us concurrently; a strategy we can leverage to speed up our run times. however, we still have a glaring problem: our parent process can’t get data back from its children.
in the next post, we’ll be looking at how to solve that by using shmop, php’s shared memory feature.
🔎 this post was originally written in the grant horwood technical blog