The Lock Component
The Lock Component creates and manages locks, a mechanism to provideexclusive access to a shared resource.
Installation
- $ composer require symfony/lock
Note
If you install this component outside of a Symfony application, you mustrequire the vendor/autoload.php
file in your code to enable the classautoloading mechanism provided by Composer. Readthis article for more details.
Usage
Locks are used to guarantee exclusive access to some shared resource. InSymfony applications, you can use locks for example to ensure that a command isnot executed more than once at the same time (on the same or different servers).
Locks are created using a Factory
class,which in turn requires another class to manage the storage of locks:
- use Symfony\Component\Lock\Factory;
- use Symfony\Component\Lock\Store\SemaphoreStore;
- $store = new SemaphoreStore();
- $factory = new Factory($store);
The lock is created by calling the createLock()
method. Its first argument is an arbitrary string that represents the lockedresource. Then, a call to the acquire()
method will try to acquire the lock:
- // ...
- $lock = $factory->createLock('pdf-invoice-generation');
- if ($lock->acquire()) {
- // The resource "pdf-invoice-generation" is locked.
- // You can compute and generate invoice safely here.
- $lock->release();
- }
If the lock can not be acquired, the method returns false
. The acquire()
method can be safely called repeatedly, even if the lock is already acquired.
Note
Unlike other implementations, the Lock Component distinguishes locksinstances even when they are created for the same resource. If a lock hasto be used by several services, they should share the same Lock
instancereturned by the Factory::createLock
method.
Tip
If you don't release the lock explicitly, it will be released automaticallyon instance destruction. In some cases, it can be useful to lock a resourceacross several requests. To disable the automatic release behavior, set thethird argument of the createLock()
method to false
.
Blocking Locks
By default, when a lock cannot be acquired, the acquire
method returnsfalse
immediately. To wait (indefinitely) until the lockcan be created, pass true
as the argument of the acquire()
method. Thisis called a blocking lock because the execution of your application stopsuntil the lock is acquired.
Some of the built-in Store
classes support this feature. When they don't,they can be decorated with the RetryTillSaveStore
class:
- use Symfony\Component\Lock\Factory;
- use Symfony\Component\Lock\Store\RedisStore;
- use Symfony\Component\Lock\Store\RetryTillSaveStore;
- $store = new RedisStore(new \Predis\Client('tcp://localhost:6379'));
- $store = new RetryTillSaveStore($store);
- $factory = new Factory($store);
- $lock = $factory->createLock('notification-flush');
- $lock->acquire(true);
Expiring Locks
Locks created remotely are difficult to manage because there is no way for theremote Store
to know if the locker process is still alive. Due to bugs,fatal errors or segmentation faults, it cannot be guaranteed that release()
method will be called, which would cause the resource to be locked infinitely.
The best solution in those cases is to create expiring locks, which arereleased automatically after some amount of time has passed (called TTL forTime To Live). This time, in seconds, is configured as the second argument ofthe createLock()
method. If needed, these locks can also be released earlywith the release()
method.
The trickiest part when working with expiring locks is choosing the right TTL.If it's too short, other processes could acquire the lock before finishing thejob; if it's too long and the process crashes before calling the release()
method, the resource will stay locked until the timeout:
- // ...
- // create an expiring lock that lasts 30 seconds
- $lock = $factory->createLock('charts-generation', 30);
- $lock->acquire();
- try {
- // perform a job during less than 30 seconds
- } finally {
- $lock->release();
- }
Tip
To avoid letting the lock in a locking state, it's recommended to wrap thejob in a try/catch/finally block to always try to release the expiring lock.
In case of long-running tasks, it's better to start with a not too long TTL andthen use the refresh()
methodto reset the TTL to its original value:
- // ...
- $lock = $factory->createLock('charts-generation', 30);
- $lock->acquire();
- try {
- while (!$finished) {
- // perform a small part of the job.
- // renew the lock for 30 more seconds.
- $lock->refresh();
- }
- } finally {
- $lock->release();
- }
Tip
Another useful technique for long-running tasks is to pass a custom TTL asan argument of the refresh()
method to change the default lock TTL:
- $lock = $factory->createLock('charts-generation', 30);
- // ...
- // refresh the lock for 30 seconds
- $lock->refresh();
- // ...
- // refresh the lock for 600 seconds (next refresh() call will be 30 seconds again)
- $lock->refresh(600);
This component also provides two useful methods related to expiring locks:getExpiringDate()
(which returns null
or a \DateTimeImmutable
object) and isExpired()
(which returns a boolean).
The Owner of The Lock
Locks that are acquired for the first time are owned[1]_ by the Lock
instance that acquiredit. If you need to check whether the current Lock
instance is (still) the owner ofa lock, you can use the isAcquired()
method:
- if ($lock->isAcquired()) {
- // We (still) own the lock
- }
Because of the fact that some lock stores have expiring locks (as seen and explainedabove), it is possible for an instance to lose the lock it acquired automatically:
- // If we cannot acquire ourselves, it means some other process is already working on it
- if (!$lock->acquire()) {
- return;
- }
- $this->beginTransaction();
- // Perform a very long process that might exceed TTL of the lock
- if ($lock->isAcquired()) {
- // Still all good, no other instance has acquired the lock in the meantime, we're safe
- $this->commit();
- } else {
- // Bummer! Our lock has apparently exceeded TTL and another process has started in
- // the meantime so it's not safe for us to commit.
- $this->rollback();
- throw new \Exception('Process failed');
- }
Caution
A common pitfall might be to use the isAcquired()
method to check ifa lock has already been acquired by any process. As you can see in this exampleyou have to use acquire()
for this. The isAcquired()
method is used to checkif the lock has been acquired by the current process only!
[1] | Technically, the true owners of the lock are the ones that share the same instance of Key ,not Lock . But from a user perspective, Key is internal and you will likely only be workingwith the Lock instance so it's easier to think of the Lock instance as being the one thatis the owner of the lock. |
Available Stores
Locks are created and managed in Stores
, which are classes that implementStoreInterface
. The component includes thefollowing built-in store types:
Store | Scope | Blocking | Expiring |
---|---|---|---|
FlockStore | local | yes | no |
MemcachedStore | remote | no | yes |
PdoStore | remote | no | yes |
RedisStore | remote | no | yes |
SemaphoreStore | local | yes | no |
ZookeeperStore | remote | no | no |
FlockStore
The FlockStore uses the file system on the local computer to create the locks.It does not support expiration, but the lock is automatically released when thePHP process is terminated:
- use Symfony\Component\Lock\Store\FlockStore;
- // the argument is the path of the directory where the locks are created
- $store = new FlockStore(sys_get_temp_dir());
Caution
Beware that some file systems (such as some types of NFS) do not supportlocking. In those cases, it's better to use a directory on a local diskdrive or a remote store based on PDO, Redis or Memcached.
MemcachedStore
The MemcachedStore saves locks on a Memcached server, it requires a Memcachedconnection implementing the \Memcached
class. This store does notsupport blocking, and expects a TTL to avoid stalled locks:
- use Symfony\Component\Lock\Store\MemcachedStore;
- $memcached = new \Memcached();
- $memcached->addServer('localhost', 11211);
- $store = new MemcachedStore($memcached);
Note
Memcached does not support TTL lower than 1 second.
PdoStore
The PdoStore saves locks in an SQL database. It requires a PDO connection, aDoctrine DBAL Connection, or a Data Source Name (DSN). This store does notsupport blocking, and expects a TTL to avoid stalled locks:
- use Symfony\Component\Lock\Store\PdoStore;
- // a PDO, a Doctrine DBAL connection or DSN for lazy connecting through PDO
- $databaseConnectionOrDSN = 'mysql:host=127.0.0.1;dbname=lock';
- $store = new PdoStore($databaseConnectionOrDSN, ['db_username' => 'myuser', 'db_password' => 'mypassword']);
Note
This store does not support TTL lower than 1 second.
Before storing locks in the database, you must create the table that storesthe information. The store provides a method calledcreateTable()
to set up this table for you according to the database engine used:
- try {
- $store->createTable();
- } catch (\PDOException $exception) {
- // the table could not be created for some reason
- }
A great way to set up the table in production is to call the createTable()
method in your local computer and then generate adatabase migration:
- $ php bin/console doctrine:migrations:diff
- $ php bin/console doctrine:migrations:migrate
RedisStore
The RedisStore saves locks on a Redis server, it requires a Redis connectionimplementing the \Redis
, \RedisArray
, \RedisCluster
or\Predis
classes. This store does not support blocking, and expects a TTL toavoid stalled locks:
- use Symfony\Component\Lock\Store\RedisStore;
- $redis = new \Redis();
- $redis->connect('localhost');
- $store = new RedisStore($redis);
SemaphoreStore
The SemaphoreStore uses the PHP semaphore functions to create the locks:
- use Symfony\Component\Lock\Store\SemaphoreStore;
- $store = new SemaphoreStore();
CombinedStore
The CombinedStore is designed for High Availability applications because itmanages several stores in sync (for example, several Redis servers). When a lockis being acquired, it forwards the call to all the managed stores, and itcollects their responses. If a simple majority of stores have acquired the lock,then the lock is considered as acquired; otherwise as not acquired:
- use Symfony\Component\Lock\Store\CombinedStore;
- use Symfony\Component\Lock\Store\RedisStore;
- use Symfony\Component\Lock\Strategy\ConsensusStrategy;
- $stores = [];
- foreach (['server1', 'server2', 'server3'] as $server) {
- $redis = new \Redis();
- $redis->connect($server);
- $stores[] = new RedisStore($redis);
- }
- $store = new CombinedStore($stores, new ConsensusStrategy());
Instead of the simple majority strategy (ConsensusStrategy
) anUnanimousStrategy
can be used to require the lock to be acquired in allthe stores.
Caution
In order to get high availability when using the ConsensusStrategy
, theminimum cluster size must be three servers. This allows the cluster to keepworking when a single server fails (because this strategy requires that thelock is acquired in more than half of the servers).
ZookeeperStore
The ZookeeperStore saves locks on a ZooKeeper server. It requires a ZooKeeperconnection implementing the \Zookeeper
class. This store does notsupport blocking and expiration but the lock is automatically released when thePHP process is terminated:
- use Symfony\Component\Lock\Store\ZookeeperStore;
- $zookeeper = new \Zookeeper('localhost:2181');
- // use the following to define a high-availability cluster:
- // $zookeeper = new \Zookeeper('localhost1:2181,localhost2:2181,localhost3:2181');
- $store = new ZookeeperStore($zookeeper);
Note
Zookeeper does not require a TTL as the nodes used for locking are ephemeraland die when the PHP process is terminated.
Reliability
The component guarantees that the same resource can't be lock twice as long asthe component is used in the following way.
Remote Stores
Remote stores (MemcachedStore,PdoStore,RedisStore andZookeeperStore) use a unique token to recognizethe true owner of the lock. This token is stored in theKey
object and is used internally bythe Lock
, therefore this key must not be shared between processes (session,caching, fork, …).
Caution
Do not share a key between processes.
Every concurrent process must store the Lock
in the same server. Otherwise twodifferent machines may allow two different processes to acquire the same Lock
.
Caution
To guarantee that the same server will always be safe, do not use Memcachedbehind a LoadBalancer, a cluster or round-robin DNS. Even if the main serveris down, the calls must not be forwarded to a backup or failover server.
Expiring Stores
Expiring stores (MemcachedStore,PdoStore andRedisStore)guarantee that the lock is acquired only for the defined duration of time. Ifthe task takes longer to be accomplished, then the lock can be released by thestore and acquired by someone else.
The Lock
provides several methods to check its health. The isExpired()
method checks whether or not its lifetime is over and the getRemainingLifetime()
method returns its time to live in seconds.
Using the above methods, a more robust code would be:
- // ...
- $lock = $factory->createLock('invoice-publication', 30);
- $lock->acquire();
- while (!$finished) {
- if ($lock->getRemainingLifetime() <= 5) {
- if ($lock->isExpired()) {
- // lock was lost, perform a rollback or send a notification
- throw new \RuntimeException('Lock lost during the overall process');
- }
- $lock->refresh();
- }
- // Perform the task whose duration MUST be less than 5 minutes
- }
Caution
Choose wisely the lifetime of the Lock
and check whether its remainingtime to leave is enough to perform the task.
Caution
Storing a Lock
usually takes a few milliseconds, but network conditionsmay increase that time a lot (up to a few seconds). Take that into accountwhen choosing the right TTL.
By design, locks are stored in servers with a defined lifetime. If the date ortime of the machine changes, a lock could be released sooner than expected.
Caution
To guarantee that date won't change, the NTP service should be disabledand the date should be updated when the service is stopped.
FlockStore
By using the file system, this Store
is reliable as long as concurrentprocesses use the same physical directory to stores locks.
Processes must run on the same machine, virtual machine or container.Be careful when updating a Kubernetes or Swarm service because for a shortperiod of time, there can be two running containers in parallel.
The absolute path to the directory must remain the same. Be careful of symlinksthat could change at anytime: Capistrano and blue/green deployment often usethat trick. Be careful when the path to that directory changes between twodeployments.
Some file systems (such as some types of NFS) do not support locking.
Caution
All concurrent processes must use the same physical file system by runningon the same machine and using the same absolute path to locks directory.
By definition, usage of FlockStore
in an HTTP context is incompatiblewith multiple front servers, unless to ensure that the same resource willalways be locked on the same machine or to use a well configured shared filesystem.
Files on the file system can be removed during a maintenance operation. For instance,to clean up the /tmp
directory or after a reboot of the machine when a directoryuses tmpfs. It's not an issue if the lock is released when the process ended, butit is in case of Lock
reused between requests.
Caution
Do not store locks on a volatile file system if they have to be reused inseveral requests.
MemcachedStore
The way Memcached works is to store items in memory. That means that by usingthe MemcachedStore the locks are not persistedand may disappear by mistake at anytime.
If the Memcached service or the machine hosting it restarts, every lock wouldbe lost without notifying the running processes.
Caution
To avoid that someone else acquires a lock after a restart, it's recommendedto delay service start and wait at least as long as the longest lock TTL.
By default Memcached uses a LRU mechanism to remove old entries when the serviceneeds space to add new items.
Caution
The number of items stored in Memcached must be under control. If it's notpossible, LRU should be disabled and Lock should be stored in a dedicatedMemcached service away from Cache.
When the Memcached service is shared and used for multiple usage, Locks could beremoved by mistake. For instance some implementation of the PSR-6 clear()
method uses the Memcached's flush()
method which purges and removes everything.
Caution
The method flush()
must not be called, or locks should be stored in adedicated Memcached service away from Cache.
PdoStore
The PdoStore relies on the ACID properties of the SQL engine.
Caution
In a cluster configured with multiple primaries, ensure writes aresynchronously propagated to every nodes, or always use the same node.
Caution
Some SQL engines like MySQL allow to disable the unique constraint check.Ensure that this is not the case SET unique_checks=1;
.
In order to purge old locks, this store uses a current datetime to define anexpiration date reference. This mechanism relies on all server nodes tohave synchronized clocks.
Caution
To ensure locks don't expire prematurely; the TTLs should be set withenough extra time to account for any clock drift between nodes.
RedisStore
The way Redis works is to store items in memory. That means that by usingthe RedisStore the locks are not persistedand may disappear by mistake at anytime.
If the Redis service or the machine hosting it restarts, every locks wouldbe lost without notifying the running processes.
Caution
To avoid that someone else acquires a lock after a restart, it's recommendedto delay service start and wait at least as long as the longest lock TTL.
Tip
Redis can be configured to persist items on disk, but this option wouldslow down writes on the service. This could go against other uses of theserver.
When the Redis service is shared and used for multiple usages, locks could beremoved by mistake.
Caution
The command FLUSHDB
must not be called, or locks should be stored in adedicated Redis service away from Cache.
CombinedStore
Combined stores allow to store locks across several backends. It's a commonmistake to think that the lock mechanism will be more reliable. This is wrongThe CombinedStore
will be, at best, as reliable as the least reliable ofall managed stores. As soon as one managed store returns erroneous information,the CombinedStore
won't be reliable.
Caution
All concurrent processes must use the same configuration, with the sameamount of managed stored and the same endpoint.
Tip
Instead of using a cluster of Redis or Memcached servers, it's better to usea CombinedStore
with a single server per managed store.
SemaphoreStore
Semaphores are handled by the Kernel level. In order to be reliable, processesmust run on the same machine, virtual machine or container. Be careful whenupdating a Kubernetes or Swarm service because for a short period of time, therecan be two running containers in parallel.
Caution
All concurrent processes must use the same machine. Before starting aconcurrent process on a new machine, check that other process are stoppedon the old one.
ZookeeperStore
The way ZookeeperStore works is by maintaining locks as ephemeral nodes on theserver. That means that by using ZookeeperStorethe locks will be automatically released at the end of the session in case theclient cannot unlock for any reason.
If the ZooKeeper service or the machine hosting it restarts, every lock wouldbe lost without notifying the running processes.
Tip
To use ZooKeeper's high-availability feature, you can setup a cluster ofmultiple servers so that in case one of the server goes down, the majoritywill still be up and serving the requests. All the available servers in thecluster will see the same state.
Note
As this store does not support multi-level node locks, since the clean up ofintermediate nodes becomes an overhead, all locks are maintained at the rootlevel.
Overall
Changing the configuration of stores should be done very carefully. Forinstance, during the deployment of a new version. Processes with newconfiguration must not be started while old processes with old configurationare still running.