Lock & Semaphore

Jérémy DERUSSÉ

Solution Architect at WebMD

@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
  • Redis
  • Semaphore
  • Zookeeper

 

  • Redis

Lock

Semaphore

behavior

  • local/remote
  • expiring
  • blocking
  • sharing

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

if (!$store instance of BlockingStoreInterface) {
    $store = new RetryTillSaveStore($store);
}

blocking

symfony/lock

pro tips  

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
{
    public function __invoke(GenerateThumb $message): void
    {
        $limit = 10;
        $weight = $message->getSourceHeight() > 3840 ? 2 : 1;
        
        $semaphore = $this->semaphoreFactory
            ->createSemaphore('generate-thumb', $limit, $weight);
      
        if (!$semaphore->acquire()) {
            throw new RecoverableException('Semaphore not available');
        }
      
        $this->thumbGenerator->generate($message);
    }
}

semaphore

class GenerateThumbHandler implements MessageHandlerInterface
{
    public function __invoke(GenerateThumb $message): void
    {
        $limit = 10;
        $weight = $message->getSourceHeight() > 3840 ? 2 : 1;
        
        $semaphore = $this->semaphoreFactory
            ->createSemaphore('generate-thumb', $limit, $weight);
      
        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->updateTransport($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->updateTransport($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

$store = new CombinedStore(
    [
        new FlockStore(),
        StoreFactory::createStore('redis://node.acme.fr'),
    ],
    new UnanimousStrategy()
);

better performances

class RestrictedAreaController extends AbstractController
{
    private const MAXIMUM_MEMBERS = 5;

    public function __invoke(Request $request)
    {
    	$session = $request->getSession();
        $key = $session->get('key') ?? new Key(
            $this->getCurrentUser()->getFamilyId(), self::MAXIMUM_MEMBERS
        );
        
        $semaphore = new Semaphore($key, $this->store, 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

cross process

public function synchronizeArticle(Article $article): void
{
    $key = new Key('article.'.$article->getId());
    $lock = new Lock($key, $this->store, 300, false);
    $lock->acquire(true);

    $this->updateArticle($article);

    $this->bus->dispatch(new RefreshTaxonomy($article, $key));
}

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->isLockAcquiredBySomeoneElse()) {
        $this->lock->acquire();
    }
    
    // ...
}
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;
    }
   
    // ...
}
function X()
{
    $a = $this->factory->createLock('A');
    $b = $this->factory->createLock('B');
    
    $a->acquire(true);
    $b->acquire(true); // ⚠️ deadlock
    
    // ...
}

!

function updateStocks(Order $order)
{
    $locks = [];
    $products = $order->getProductsOrderById();
    foreach($products as $product) {
        $locks[] = $lock = $this->factory
            ->createLock($product->getId());
        $lock->acquire(true);
    }

    // ...
}
function updateStocks(Order $order)
{
    $locks = [];
    $products = $order->getProducts();
    foreach($products as $product) {
        $locks[] = $lock = $this->factory
            ->createLock($product->getId());
        $lock->acquire(true);
    }

    // ...
}
function Y()
{
    $a = $this->factory->createLock('A');
    $b = $this->factory->createLock('B');
    
    $b->acquire(true);
    $a->acquire(true); // ⚠️ deadlock
    
    // ...
}

Is it reliable?

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

reliability

Is it reliable?

YES, if you read the doc!

Thank you!

@jderusse

slides     

Lock & Semaphore - Symfony Live 2020

By Jérémy Derussé

Lock & Semaphore - Symfony Live 2020

  • 3,073