DEV Community

Sergii Dolgushev
Sergii Dolgushev

Posted on

Getting Symfony app ready for Swoole, RoadRunner, and FrankenPHP (no AI involved)

Greetings dear reader! It has been a while since I wrote last time, so it is very nice to see you! And I hope this post will be interesting, and you find something fresh in it. On this note, I need to warn you: if you are familiar with the Shared Memory Model that is introduced by Swoole, and RoadRunner, FrankenPHP and you are 100% certain that all the services in your codebase are stateless, you might skip reading further. Otherwise, welcome!

I will use a simple Symfony 7.0 application in this post, but all the concepts apply to any PHP codebase.


The Problem

Shared Nothing Model

Usually, the PHP-FPM (FastCGI Process Manager) takes all requests for the PHP application and distributes them to individual PHP processes, also called workers. Each worker handles one request in a time. So the more workers are running, the more requests in parallel can be handled. In this case Shared Nothing Model is implemented by running the garbage collector to clear the memory between the requests (within the same worker). So no memory is shared between the requests:

shared nothing model

There is nothing special about it, and it is how PHP applications have been running for a while.

Shared Memory Model

But it changes after Swoole / RoadRunner / FrankenPHP (Worker Mode) is used. All of them use Shared Memory Model, which gives its power:

shared memory model

So within the same worker, the memory is shared to handle different requests. It leads to performance improvements, but in the same way, it might lead to some unexpected side effects, that are hard to notice and debug. Let's demonstrate the simplest way to reproduce it.

How to reproduce

We will use php-shared-memory-model repository to showcase unexpected side effects caused by the Shared Memory Model. It contains a single TestController controller:



<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class TestController extends AbstractController
{
    private int $counter = 0;

    #[Route(path: '/test', name: 'test', methods: [Request::METHOD_GET])]
    public function testAction(): Response
    {
        $content = '['.\date('c').'] Counter: '.(++$this->counter).PHP_EOL;

        return new Response($content, Response::HTTP_OK, ['Content-Type' => 'text/html']);
    }
}


Enter fullscreen mode Exit fullscreen mode

And is quite simple to install:



cd ~/Projects
git clone git@github.com:SerheyDolgushev/php-shared-memory-model.git
cd php-shared-memory-model
composer install


Enter fullscreen mode Exit fullscreen mode

Let's run it using local PHP server:



php -S 127.0.0.1:8000 public/index.php


Enter fullscreen mode Exit fullscreen mode

And send a few test requests to the test controller:



% curl http://127.0.0.1:8000/test
[2024-02-24T08:05:03+00:00] Counter: 1
% curl http://127.0.0.1:8000/test
[2024-02-24T08:05:07+00:00] Counter: 1
% curl http://127.0.0.1:8000/test
[2024-02-24T08:05:10+00:00] Counter: 1


Enter fullscreen mode Exit fullscreen mode

Nothing surprising at this point, all the responses return the expected counter.

Now let's follow additional installation instructions for Swoole, RoadRunner, and FrankenPHP, and run test the same controller in any of those runtimes (Swoole is used in this example):



APP_RUNTIME=Runtime\\Swoole\\Runtime php -d extension=swoole.so public/swoole.php


Enter fullscreen mode Exit fullscreen mode

Test requests:



% curl http://127.0.0.1:8000/test
[2024-02-24T08:07:59+00:00] Counter: 1
% curl http://127.0.0.1:8000/test
[2024-02-24T08:08:02+00:00] Counter: 2
% curl http://127.0.0.1:8000/test
[2024-02-24T08:08:06+00:00] Counter: 3


Enter fullscreen mode Exit fullscreen mode

As you can see, the counter value increased for each response, which is explained by the Shared Memory Model, but might be unexpected in some cases. And it definitely needs to be fixed.

What to fix

At this point, it is clear that all the services need to be stateless, otherwise in some cases the app might behave in the unexpected way. In order to make sure all the services in our app are stateless we can manually check them. Which can be acceptable in smaller projects, but is very tedious in larger codebases. There should be a simpler way to do it, right?

I feel now is the perfect moment to introduce the tool I have been working during the last few weekends. Ladies and gentlemen, please meet phanalist. It is a static analyzer for PHP projects, that is written in RUST. It is extremely easy to use: you just download compiled binary for your platform and run it (no PHP runtime is required). It comes with some useful rules, but we will be focused on E0012. As this is the rule that checks if services are stateless.

Let's install phanalist:



% curl --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/denzyldick/phanalist/main/bin/init.sh | sh

info: Downloading https://raw.githubusercontent.com/denzyldick/phanalist/main/release/aarch64-apple-darwin/phanalist ...
info: Saved /Users/sergiid/phanalist


Enter fullscreen mode Exit fullscreen mode

And run it only against E0012 rule, checking ./src path:



% ~/phanalist -r E0012 -s ./src
The new ./phanalist.yaml configuration file as been created

Scanning files in ./src ...
██████████████████████████████████████████████████████████ 4/4
./src/Controller/TestController.php, detected 1 violations:
  E0012:    Setting service properties leads to issues with Shared Memory Model (FrankenPHP/Swoole/RoadRunner). Trying to set $this->counter property
  19:52 |         $content = '['.\date('c').'] Counter: '.(++$this->counter).PHP_EOL;

+-----------+------------------------------------------------+------------+
| Rule Code | Description                                    | Violations |
+-----------+------------------------------------------------+------------+
| E0012     | Service compatibility with Shared Memory Model |          1 |
+-----------+------------------------------------------------+------------+

Analysed 2 files in 2.30ms, memory usage: 4.6 MiB


Enter fullscreen mode Exit fullscreen mode

After the first run ./phanalist.yaml will be created. And depending on your project, you might want to adjust configurations for E0012 rule.

Most likely, you will get a different output for your project. But it will contain all the stateful services, that need to be converted to stateless.

How to fix

Converting a list of services to stateless might be not such an easy task, especially if there are a lot of services that you are not familiar with. Manually modifying the code that sets the state for the service might be very challenging, because of myriad of reasons: you might not be aware of all the use cases for the service/it might be a legacy code that is hard to understand/you name it. And again, there should be a better approach to handle it, right?

The good news is that there is a better and simpler way to do it: Symfony provides a ResetInterface interface, that is designed to be used exactly in cases like this. Hopefully, other frameworks have something similar.

So now we need just to apply ResetInterface for each stateful service. So TestController will become:



% git diff src/Controller/TestController.php
diff --git a/src/Controller/TestController.php b/src/Controller/TestController.php
index 82b06b3..e01962c 100644
--- a/src/Controller/TestController.php
+++ b/src/Controller/TestController.php
@@ -8,8 +8,9 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\Routing\Annotation\Route;
+use Symfony\Contracts\Service\ResetInterface;

-class TestController extends AbstractController
+class TestController extends AbstractController implements ResetInterface
 {
     private int $counter = 0;

@@ -20,4 +21,9 @@ class TestController extends AbstractController

         return new Response($content, Response::HTTP_OK, ['Content-Type' => 'text/html']);
     }
+
+    public function reset()
+    {
+        $this->counter = 0;
+    }
 }


Enter fullscreen mode Exit fullscreen mode

Let's restart Swoole runtime after this change



APP_RUNTIME=Runtime\\Swoole\\Runtime php -d extension=swoole.so public/swoole.php


Enter fullscreen mode Exit fullscreen mode

And send a few test requests:



% curl http://127.0.0.1:8000/test
[2024-03-04T09:37:29+00:00] Counter: 1
% curl http://127.0.0.1:8000/test
[2024-03-04T09:37:30+00:00] Counter: 1
% curl http://127.0.0.1:8000/test
[2024-03-04T09:37:31+00:00] Counter: 1


Enter fullscreen mode Exit fullscreen mode

As you can see, now the counter values for each response are the same as expected.

And I worth to mention that phanalist is smart enough to not report controllers that implement ResetInterface (configurable in phanalist.yaml). So ./src/Controller/TestController.php is not included in its output anymore:



% ~/phanalist -r E0012 -s ./src
Using configuration file ./phanalist.yaml

Scanning files in ./src ...
██████████████████████████████████████████████████████████ 4/4
Analysed 2 files in 9.26ms, memory usage: 4.9 MiB

Enter fullscreen mode Exit fullscreen mode




Conclusions

Shared Memory Model examples are reproducible only within the same worker, that's why all runtimes php-shared-memory-model are forced to a single worker. Reproduce similar cases in a production environment there might be dozens for running workers might be very tricky.

I hope this post introduced something new for you and provided you with a simple solution to a complex problem. Wish you to run your PHP apps faster and always get the expected results!

Top comments (3)

Collapse
 
cviniciussdias profile image
Vinicius Dias

Very nice! Thank you for sharing.
In that example, even after implementing the interface, we might see a counter value > 1, right?

In a race condition where a request accesses the value before its reset...

Does having repositories in controllers constructors lead to pdo connections remaining forever open? Is there a workaround?

Once again, thanks for sharing. :-D

Collapse
 
sergiid profile image
Sergii Dolgushev

Thank you very much for your question. The memory is shared within the same worker, and each worker can handle one request at a time.

Let's assume we have only one worker available and two concurrent requests were sent. The following will happen:

  1. The worker will start processing the request #1. Request #2 will be put "on hold"
  2. During request #1 processing, the memory state might change, but in the end, it will be reset because of ResetInterface.
  3. Once request #1 is processed, the worker will start processing request #2.

In this case, we can't actually get the race condition, because a single worker processes requests one at a time. The race condition is possible only with multiple workers. But in this case, they don't share the memory. So it shouldn't be a concern.

I was using the following example (with 5 seconds delay):

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\Service\ResetInterface;

class TestController extends AbstractController implements ResetInterface
{
    private int $counter = 0;

    #[Route(path: '/test', name: 'test', methods: [Request::METHOD_GET])]
    public function testAction(): Response
    {
        $content = '['.\date('c').'] Counter: '.(++$this->counter).PHP_EOL;

        sleep(5);

        return new Response($content, Response::HTTP_OK, ['Content-Type' => 'text/html']);
    }

    public function reset()
    {
        $this->counter = 0;
    }
}
Enter fullscreen mode Exit fullscreen mode

And was sending 2 requests within 5 seconds, to emulate the race condition.

Tried single frankenphp worker:

APP_RUNTIME=Runtime\\FrankenPhpSymfony\\Runtime /usr/local/bin/frankenphp php-server -l 127.0.0.1:8000 -w ./index.php,1
Enter fullscreen mode Exit fullscreen mode

And got the expected 1 for both requests, their total execution time was around 10 seconds, as the second one was "on hold" while the first one was processing.

And also checked frankenphp with two workers:

APP_RUNTIME=Runtime\\FrankenPhpSymfony\\Runtime /usr/local/bin/frankenphp php-server -l 127.0.0.1:8000 -w ./index.php,2
Enter fullscreen mode Exit fullscreen mode

And again expected result was returned for both requests, and the total execution time was around 5 seconds, as the second request was not waiting for the first one to be processed. The expected result is returned for both requests even without ResetInterface. As requests are handled by different workers that don't share the memory.

Dear @cviniciussdias I hope it helps with your question. Otherwise can you please provide an example to reproduce the case you are referring to?

Collapse
 
aerendir profile image
Adamo Crespi • Edited

Phanalist looks like a really useful tool: I was searching for a tool that would fail if set cyclomatic complexity would have been over a threshold and it seems it does exactly this.

Maybe I don’t have yet a real use case to test the stateless rules, but knowing this tool exists is for sure a good starting point!

Going to read the other articles in this series: that I you!