Lock & Semaphore

Jérémy DERUSSÉ

Solution Architect at WebMD

@symfony core team

@jderusse

function updateUser(string $userId, array $changes)
{


    $data = $this->client->request('GET', "/users/$userId")->toArray();
    
    $data = array_merge($data, $changes);
    
    $this->client->request('PUT', "/users/$userId", ['body' => $data]);


}
function updateUser(string $userId, array $changes)
{
    $this->limitToOneProcess();

    $data = $this->client->request('GET', "/users/$userId")->toArray();
   
    $data = array_merge($data, $changes);
    
    $this->client->request('PUT', "/users/$userId", ['body' => $data]);

    $this->removeLimit();
}

race condition

symfony/lock
symfony/semaphore

“To make a quick comparison with a lock:

  • A lock allows only 1 process to access a resource
  • A semaphore allow N process to access a resource

 

Basically, a lock is a semaphore where N = 1.

@lyrixx

symfony/lock

final class Key
{
    /** @var string */
    private $resource;
    /** @var array */
    private $state = [];
}
interface PersistingStoreInterface
{
    public function save(Key $key);
    public function delete(Key $key);
    public function exists(Key $key);
    // ...
}
final class Lock implements LockInterface
{
    /** @var Key */
    private $key;
    /** @var PersistingStoreInterface */
    private $store;

    public function acquire() {}
    public function release() {}
    // ...
}

symfony/semaphore

final class Key
{
    /** @var string */
    private $resource;
    /** @var int */
    private $limit;
    /** @var array */
    private array $state = [];
}
interface PersistingStoreInterface
{
    public function save(Key $key);
    public function delete(Key $key);
    public function exists(Key $key);
    // ...
}
final class Semaphore implements SemaphoreInterface
{
    /** @var Key */
    private $key;
    /** @var PersistingStoreInterface */
    private $store;

    public function acquire();
    public function release();
    // ...
}

available stores

Flock

Memcached

MongoDb

Pdo

Postgres

Redis

Semaphore

Zookeeper

lock

5.2

semaphore

5.2

remote

expiring

blocking

sharing

5.2

how to use it?

use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\FlockStore;

$store = new FlockStore();
$factory = new LockFactory($store);

$lock = $factory->createLock('pdf-invoice-generation');

if ($lock->acquire()) {
    try {
        generateInvoice();
    } finally {
        $lock->release();
    }
}

auto-release

function __invoke()
{
    $lock = $this->lockFactory->createLock('pdf-invoice-generation');
    if (!$lock->acquire()) {
        return;
    }
    
    $this->task();
}
function updateUser(string $userId, array $changes)
{
    $lock = $this->lockFactory
    	->createLock("/users/$userId");
    $lock->acquire(true);

    $data = $this->client->request('GET', "/users/$userId")->toArray();
   
    $data = array_merge($data, $changes);
    
    $this->client->request('PUT', "/users/$userId", ['body' => $data]);
}

blocking

function generateInvoices()
{
    $lock = $this->lockFactory->createLock('generate_invoices', 30);
    if (!$lock->acquire()) {
        return;
    }
    foreach ($this->getPendingInvoices() as $invoice) {
    	$lock->refresh();
        
        $this->generateInvoice($invoice);
    }
}

expiration

class GenerateThumbHandler implements MessageHandlerInterface
{
    private const LIMIT = 10;
    
    public function __invoke(GenerateThumb $message): void
    {
        $semaphore = $this->semaphoreFactory
            ->createSemaphore('generate-thumb', self::LIMIT);
      
        if (!$semaphore->acquire()) {
            throw new RecoverableException('Semaphore not available');
        }
      
        $this->thumbGenerator->generate($message);
    }
}

semaphore

class GenerateThumbHandler implements MessageHandlerInterface
{
    private const LIMIT = 10;
    
    public function __invoke(GenerateThumb $message): void
    {
        $semaphore = $this->semaphoreFactory
            ->createSemaphore('generate-thumb', self::LIMIT);
      
        if (!$semaphore->acquire()) {
            throw new RecoverableException('Semaphore not available');
        }
      
        $this->thumbGenerator->generate($message);
    }
}

semaphore

pro tips  

function addItem(Order $order, Item $item)
{




    $order->items->add($item);

    $this->updateOrder($order);

    $this->updateShipping($order);

    $this->updateTotalAmount($order);
}

shared locks

function displayOrderAction(string $orderId)
{




    $order = $this->orderRepository
        ->fetch($orderId);
    
    return $this->render(
        'order.html.twig', 
        ['order' => $order]
    );
}
function addItem(Order $order, Item $item)
{
    $lock = $this->lockFactory
        ->createLock($order->getId());
    $lock->acquire(true);
    
    $order->items->add($item);

    $this->updateOrder($order);

    $this->updateShipping($order);

    $this->updateTotalAmount($order);
}

5.2

function displayOrderAction(string $orderId)
{
    $lock = $this->lockFactory
      ->createLock($orderId);
    $lock->acquireRead(true);

    $order = $this->orderRepository
        ->fetch($orderId);
    
    return $this->render(
        'order.html.twig', 
        ['order' => $order]
    );
}

advanced usage

$store = new CombinedStore(
    [
        StoreFactory::createStore('redis://node1.acme.fr'),
        StoreFactory::createStore('redis://node2.acme.fr'),
        StoreFactory::createStore('redis://node3.acme.fr'),
    ],
    new ConsensusStrategy()
);

high availability

class RestrictedAreaController extends AbstractController
{
    private const MAXIMUM_MEMBERS = 5;

    public function __invoke(SessionInterface $session)
    {
        $key = $session->get('key') ?? new Key(
            $this->getCurrentUser()->getFamilyId(), self::MAXIMUM_MEMBERS
        );
        $semaphore = $this->factory->createLockFromKey($key, 300, false);
        
        if (!$semaphore->acquire()) {
            return $this->redirectToRoute('waiting_room.html.twig');
        }
        $semaphore->refresh();
        $session->set('key', $key);

        return $this->render('restricted_area.html.twig');
    }
}

cross process

common pitfalls

function __invoke(): void
{
    $this->lock->acquire();
    // ⚠ result not checked
    
    // ...
}
function __invoke(): void
{
    if (!$this->lock->acquire()) {
        return;
    }
    
    // ...
}
function __invoke(): void
{
    $this->lock->acquire(true); // blocking mode
    
    // ...
}
function __invoke(): void
{
    if (!$this->lockFactory
          ->createLock('resource')
          ->acquire()
    ) {
        return;
    }
    
    // ...
}
function __invoke(): void
{
    $lock = $this->lockFactory
    	->createLock('resource');
         
    if (!$lock->acquire()) {
        return;
    }
    
    // ...
}
function __construct(LockInterface $lock)
{
    $this->lock = $lock;
}
function __construct(LockFactory $factory)
{
    $this->factory = $factory;
}

!

function __invoke()
{
    if (!$this->lock->acquire()) {
        return;
    }
   
    $this->lock->acquire(); // true
    $this->lock->acquire(); // true
}
function __invoke()
{
    $lock = $this->factory->createLock(__CLASS__);
    if (!$lock->acquire()) {
        return;
    }
}

Is it reliable?

  • flock + NFS
  • memcached/redis + reboot
  • pdo/redis + replication
  • synchronized clocks
  • ntp update
  • semaphore + non-system user
  • postgres/zookeep + network
  • memcached/redis + LRU
  • memcached/redis + flush
  • memcached + loadbalancer

reliability

Is it reliable?

YES, if you read the doc!

Thank you!

@jderusse

slides     

Lock & Semaphore - Symfony World 2020

By Jérémy Derussé

Lock & Semaphore - Symfony World 2020

  • 11,183