Dealing with Concurrency with Locks

Dealing with Concurrency with Locks

When a program runs concurrently, some part of code which modify shared resources should not be accessed by multiple processes at the same time. Symfony’s Lock component provides a locking mechanism to ensure that only one process is running the critical section of code at any point of time to prevent race condition from happening.

The following example shows a typical usage of the lock:

$lock = $lockFactory->createLock('pdf-invoice-generation');
if (!$lock->acquire()) {
    return;
}

// critical section of code
$service->method();

$lock->release();

Installation

In applications using Symfony Flex, run this command to install the Lock component:

1
$ composer require symfony/lock

Configuring Lock with FrameworkBundle

By default, Symfony provides a Semaphore when available, or a Flock otherwise. You can configure this behavior by using the lock key like:

  • YAML
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    # config/packages/lock.yaml
    framework:
        lock: ~
        lock: 'flock'
        lock: 'flock:///path/to/file'
        lock: 'semaphore'
        lock: 'memcached://m1.docker'
        lock: ['memcached://m1.docker', 'memcached://m2.docker']
        lock: 'redis://r1.docker'
        lock: ['redis://r1.docker', 'redis://r2.docker']
        lock: 'zookeeper://z1.docker'
        lock: 'zookeeper://z1.docker,z2.docker'
        lock: 'sqlite:///%kernel.project_dir%/var/lock.db'
        lock: 'mysql:host=127.0.0.1;dbname=lock'
        lock: 'pgsql:host=127.0.0.1;dbname=lock'
        lock: 'sqlsrv:server=localhost;Database=test'
        lock: 'oci:host=localhost;dbname=test'
        lock: '%env(LOCK_DSN)%'
    
        # named locks
        lock:
            invoice: ['semaphore', 'redis://r2.docker']
            report: 'semaphore'
    
  • XML
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    <!-- config/packages/lock.xml -->
    <?xml version="1.0" encoding="UTF-8" ?>
    <container xmlns="http://symfony.com/schema/dic/services"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns:framework="http://symfony.com/schema/dic/symfony"
        xsi:schemaLocation="http://symfony.com/schema/dic/services
            https://symfony.com/schema/dic/services/services-1.0.xsd
            http://symfony.com/schema/dic/symfony https://symfony.com/schema/dic/symfony/symfony-1.0.xsd">
    
        <framework:config>
            <framework:lock>
                <framework:resource>flock</framework:resource>
    
                <framework:resource>flock:///path/to/file</framework:resource>
    
                <framework:resource>semaphore</framework:resource>
    
                <framework:resource>memcached://m1.docker</framework:resource>
    
                <framework:resource>memcached://m1.docker</framework:resource>
                <framework:resource>memcached://m2.docker</framework:resource>
    
                <framework:resource>redis://r1.docker</framework:resource>
    
                <framework:resource>redis://r1.docker</framework:resource>
                <framework:resource>redis://r2.docker</framework:resource>
    
                <framework:resource>zookeeper://z1.docker</framework:resource>
    
                <framework:resource>zookeeper://z1.docker,z2.docker</framework:resource>
    
                <framework:resource>sqlite:///%kernel.project_dir%/var/lock.db</framework:resource>
    
                <framework:resource>mysql:host=127.0.0.1;dbname=lock</framework:resource>
    
                <framework:resource>pgsql:host=127.0.0.1;dbname=lock</framework:resource>
    
                <framework:resource>sqlsrv:server=localhost;Database=test</framework:resource>
    
                <framework:resource>oci:host=localhost;dbname=test</framework:resource>
    
                <framework:resource>%env(LOCK_DSN)%</framework:resource>
    
                <!-- named locks -->
                <framework:resource name="invoice">semaphore</framework:resource>
                <framework:resource name="invoice">redis://r2.docker</framework:resource>
                <framework:resource name="report">semaphore</framework:resource>
            </framework:lock>
        </framework:config>
    </container>
    
  • PHP
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    // config/packages/lock.php
    $container->loadFromExtension('framework', [
        'lock' => null,
        'lock' => 'flock',
        'lock' => 'flock:///path/to/file',
        'lock' => 'semaphore',
        'lock' => 'memcached://m1.docker',
        'lock' => ['memcached://m1.docker', 'memcached://m2.docker'],
        'lock' => 'redis://r1.docker',
        'lock' => ['redis://r1.docker', 'redis://r2.docker'],
        'lock' => 'zookeeper://z1.docker',
        'lock' => 'zookeeper://z1.docker,z2.docker',
        'lock' => 'sqlite:///%kernel.project_dir%/var/lock.db',
        'lock' => 'mysql:host=127.0.0.1;dbname=lock',
        'lock' => 'pgsql:host=127.0.0.1;dbname=lock',
        'lock' => 'sqlsrv:server=localhost;Database=test',
        'lock' => 'oci:host=localhost;dbname=test',
        'lock' => '%env(LOCK_DSN)%',
    
        // named locks
        'lock' => [
            'invoice' => ['semaphore', 'redis://r2.docker'],
            'report' => 'semaphore',
        ],
    ]);
    

Locking a Resource

To lock the default resource, autowire the lock using Symfony\Component\Lock\LockInterface (service id lock):

// src/Controller/PdfController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Lock\LockInterface;

class PdfController extends AbstractController
{
    /**
     * @Route("/download/terms-of-use.pdf")
     */
    public function downloadPdf(LockInterface $lock, MyPdfGeneratorService $pdf)
    {
        $lock->acquire(true);

        // heavy computation
        $myPdf = $pdf->getOrCreatePdf();

        $lock->release();

        // ...
    }
}

Caution

The same instance of LockInterface won’t block when calling acquire multiple times inside the same process. When several services use the same lock, inject the LockFactory instead to create a separate lock instance for each service.

Locking a Dynamic Resource

Sometimes the application is able to cut the resource into small pieces in order to lock a small subset of process and let other through. In our previous example with see how to lock the $pdf->getOrCreatePdf('terms-of-use') for everybody, now let’s see how to lock $pdf->getOrCreatePdf($version) only for processes asking for the same $version:

// src/Controller/PdfController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Lock\LockFactory;

class PdfController extends AbstractController
{
    /**
     * @Route("/download/{version}/terms-of-use.pdf")
     */
    public function downloadPdf($version, LockFactory $lockFactory, MyPdfGeneratorService $pdf)
    {
        $lock = $lockFactory->createLock($version);
        $lock->acquire(true);

        // heavy computation
        $myPdf = $pdf->getOrCreatePdf($version);

        $lock->release();

        // ...
    }
}

Named Lock

If the application needs different kind of Stores alongside each other, Symfony provides named lock:

  • YAML
    1
    2
    3
    4
    5
    # config/packages/lock.yaml
    framework:
        lock:
            invoice: ['semaphore', 'redis://r2.docker']
            report: 'semaphore'
    
  • XML
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    <!-- config/packages/lock.xml -->
    <?xml version="1.0" encoding="UTF-8" ?>
    <container xmlns="http://symfony.com/schema/dic/services"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns:framework="http://symfony.com/schema/dic/symfony"
        xsi:schemaLocation="http://symfony.com/schema/dic/services
            https://symfony.com/schema/dic/services/services-1.0.xsd
            http://symfony.com/schema/dic/symfony https://symfony.com/schema/dic/symfony/symfony-1.0.xsd">
    
        <framework:config>
            <framework:lock>
                <framework:resource name="invoice">semaphore</framework:resource>
                <framework:resource name="invoice">redis://r2.docker</framework:resource>
                <framework:resource name="report">semaphore</framework:resource>
            </framework:lock>
        </framework:config>
    </container>
    
  • PHP
    1
    2
    3
    4
    5
    6
    7
    // config/packages/lock.php
    $container->loadFromExtension('framework', [
        'lock' => [
            'invoice' => ['semaphore', 'redis://r2.docker'],
            'report' => 'semaphore',
        ],
    ]);
    

Each name becomes a service where the service id suffixed by the name of the lock (e.g. lock.invoice). An autowiring alias is also created for each lock using the camel case version of its name suffixed by Lock - e.g. invoice can be injected automatically by naming the argument $invoiceLock and type-hinting it with Symfony\Component\Lock\LockInterface.

Symfony also provide a corresponding factory and store following the same rules (e.g. invoice generates a lock.invoice.factory and lock.invoice.store, both can be injected automatically by naming respectively $invoiceLockFactory and $invoiceLockStore and type-hinted with Symfony\Component\Lock\LockFactory and Symfony\Component\Lock\PersistingStoreInterface)

Blocking Store

If you want to use the RetryTillSaveStore for non-blocking locks, you can do it by decorating the store service:

1
2
3
4
lock.default.retry_till_save.store:
    class: Symfony\Component\Lock\Store\RetryTillSaveStore
    decorates: lock.default.store
    arguments: ['@lock.default.retry_till_save.store.inner', 100, 50]

This work, including the code samples, is licensed under a Creative Commons BY-SA 3.0 license.