Let's start with the basics of how a CPU works.
Each core in a multi-core processor goes through a continuous cycle of fetching and executing instructions that are read from memory one by one. The operating system shares the memory address where the instruction is stored, and the core retrieves it, decodes it, and executes it.
While fetching an instruction, the core increments an internal pointer that points to the address of the next instruction in memory (known as the program counter).
Something like this:
$programCounter = 1;
while (true) {
$instruction = fetch($programCounter);
$programCounter++;
$instruction->execute();
}
During the execution stage, the core interacts with internal storage units known as "Registers". These registers allow the core to store the state of a program between instructions.
In reality, the operating system does not permit the core to keep executing instructions for a single program indefinitely. It interrupts the cycle on certain events—or after a certain time—and redirects the core attention to instructions from another program that must be executed.
This switch of attention is known as "Context Switching". It looks something like this:
$programCounter = 1;
while (true) {
$instruction = fetch($programCounter);
$programCounter++;
$instruction->execute();
if ($interrupt) {
saveState();
$programCounter = getNewAddress();
}
}
From the perspective of the end user, the core is doing nothing useful during the context switching process as it isn't executing any instructions from programs the user is running.
These context switches are so fast that the user will not notice them, but too many context switches will cause things to appear slow.
When a user enters a character into a word processing program, for example, they expect the character to be printed on the screen immediately. However, this printing may be delayed if there is too much context switching, because the core is jumping between instructions from too many programs.
Context switching may appear to be evil, but it is the primary reason that a computer can multitask when the number of programs running exceeds the number of CPU cores. The operating system alternates between programs so that their instructions can be executed concurrently on the available CPU cores.
To increase the performance of a machine that's running too many programs, we need to add more CPU cores. This will allow us to run more instructions in parallel and fewer concurrently. As a result, there will be less context switching.
In addition, we must also increase the machine's memory capacity. Because each program occupies an isolated space in memory that it does not share with any other program.
Even if there are multiple instances of the same program running, each instance will have its own memory space.
Three PHP programs/processes are running in this diagram. Each process has its own memory space and only one execution thread. As a result, there is more memory consumption and context switching.
Multi-threading
The concept of multi-threading emerges to reduce the overhead of having too many processes running.
With multi-threading, a single program can launch multiple threads of execution that share the same memory space. This enables the program to run multiple instructions in parallel (on multiple CPU cores) or concurrently (alternating on a single core) without using up too much memory.
Furthermore, context switching between threads of the same program is simpler than context switching between different programs. Because there are fewer registers that need to be updated since part of the state is shared between threads.
One process is running in this diagram. It has three execution threads and uses a single memory space. As a result, memory consumption and context switching are reduced.
PHP
PHP does not support multi-threading. To handle multiple incoming requests at once, we must launch multiple processes. That's what PHP-FPM does, which may seem to be a bad thing, but it is one of the reasons why writing PHP programs is so enjoyable.
Since memory is not shared between concurrent requests, we don't have to worry about race conditions, deadlocks, or atomicity planning. Each process has its own memory that it can interact with without worrying about other threads interfering and changing the values.
Consider PHP in the same way that each instance of our program has its own database that no other instance can access. This simplifies things, since we don't have to worry about instances competing to read or write the same record in the same table.
In multi-threaded programs, we must exercise extreme caution when reading and writing from and to memory. This lengthens the time it takes us developers to get things done.
PHP enables us to quickly launch web applications and present them to the world. Many of these applications will never see the kind of traffic that forces system administrators to launch an excessive number of processes, costing hundreds of thousands of dollars in CPU cores and memory.
However, some of these applications will. And in that case, cost control becomes a major business concern. Engineering teams must shift some of their focus to cost optimization.
To avoid starting too many instances of those PHP programs, we must optimize our code to handle requests as quickly as possible. Caching and optimizing database queries assist us in accomplishing this.
But, after we've shaved every millisecond from I/O operations (HTTP requests, database & cache queries, etc...), it's time to look at where our CPU time and memory are spent.
GoLang
Go was built with concurrency in mind. Its execution model is entirely different from that of PHP. It makes use of multi-threading, but with a twist.
A Go program launches several threads. This allows it to make use of the various CPU cores available to execute instructions in parallel. It also improves memory utilization because the program code is only loaded into memory once.
Go also has a smaller execution unit called "Goroutines" that sits on top of threads. Each incoming request is handled by a separate goroutine, which is then scheduled to run on available threads. This means that a single thread can be tasked with carrying out instructions for multiple goroutines concurrently.
When a goroutine enters a waiting state, the Go runtime moves it aside in favor of another (a goroutine enters a waiting state when it's performing an I/O operation). While in this state, it doesn't need to execute any instructions on the CPU.
By doing so, the runtime ensures the CPU cores are fully utilized executing user-land instructions. They spend less time switching contexts and more time executing instructions to deal with incoming traffic.
Context switching between goroutines consumes less overhead than switching between threads or processes. It occurs entirely within the Go runtime environment and requires little attention from the operating system.
Aside from the many advantages that goroutines provide, shared memory enables connection pooling. This allows us to open a set of connections to resources such as databases and HTTP servers, keep them alive, and share them across multiple execution units.
Connection pooling eliminates the need for opening new connections every time we need to interact with a network service. Furthermore, when a goroutine no longer requires a connection, it is returned to the shared pool. It is not required to sit idle while waiting for the program to terminate, as is the case with PHP.
Finally, Go is a compiled language. The program is built into a single static binary that runs on a server with zero dependencies. We don't need to install a runtime interpreter or a process manager, which reduces the memory & CPU consumption further.
Adding the pieces together
PHP enables us to move quickly and has a large talent pool filled with brilliant developers. Furthermore, its ecosystem is packed with excellent libraries and frameworks.
On the other hand, Go allows us to consume less memory & CPU power and perform fewer OS-level context switches.
Should we abandon PHP and rewrite everything in Go as the traffic to our web application grows?
No!
Why should development speed be sacrificed for cost control? Why not have it both ways?
Why don't we examine our logs and metrics dashboards, identify the endpoints that are experiencing high traffic while performing excessive I/O operations, and port them to Go?
By doing so, we decrease the load on our PHP application servers and allow the remaining endpoints to serve users using the available CPU & memory resources. The Go program then handles the other endpoints in a more efficient manner.
Here's a suggested architecture:
A load balancer sits between our PHP & Go backends and frontend clients. It handles SSL negotiations and routes requests for specific endpoints to our Go backend, while all other traffic goes to the PHP backend.
Both the PHP and Go backends interact with the same set of accessories, like database clusters, cache clusters, and task queues.
Another Go program runs in the background and processes queued & periodic tasks (scheduled jobs), which relieves the PHP backend of background processing load.
Concurrent connections to the database are controlled by the number of PHP FPM workers, the connection pool size of the Go web server, and the pool size of the Go worker server.
One possibility is to use an RDS proxy between the PHP servers and the RDS cluster. This allows connections to be shared among multiple PHP workers, reducing the number of concurrent open connections. For the time being, we are hesitant to add any more pieces that would increase the cost though.
Conclusion
By employing a polyglot architecture, we get the best of both worlds. PHP provides the development speed required to compete in a hyper-growth market, while Go provides more efficient resource utilization.
Before you get motivated and start looking for Go learning resources on the internet, ask yourself: Are you currently struggling with compute infrastructure costs?
If the answer is no, continue to use PHP. It will help you move faster, add value, onboard more customers, and hopefully generate enough traffic to cause infrastructure cost issues. These issues are nice to have, especially since you now know how to deal with them :)
If the answer is yes, or if you want to learn Go for the future, I recommend checking this course. I built it specifically for PHP developers who want to learn Go.
I've been a PHP developer for over a decade (two decades if you count the school projects I built when I was 13), so I understand the mental shifts you'll go through when learning a compiled language with concurrency primitives as first class citizens.
I believe I did a good job in easing the transition in the course. But that is up to you to decide :)
I'm Mohamed Said. I work with companies and teams all over the world to build and scale web applications in the cloud. Find me on twitter @themsaid.