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,035