Skip to content

Commit 82672fd

Browse files
#6 Timeout for FlockMutex
1 parent 8523cbf commit 82672fd

File tree

9 files changed

+207
-28
lines changed

9 files changed

+207
-28
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ $mutex->check(function () use ($bankAccount, $amount) {
6262

6363
### Implementations
6464

65-
The `Mutex` is an abstract class. You will have to chose an implementation:
65+
The `Mutex` is an abstract class. You will have to choose an implementation:
6666

6767
- [`CASMutex`](#casmutex)
6868
- [`FlockMutex`](#flockmutex)

classes/exception/ExecutionOutsideLockException.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,16 @@
1313
*/
1414
class ExecutionOutsideLockException extends LockReleaseException
1515
{
16+
public static function create($elapsed_time, $timeout)
17+
{
18+
$message = sprintf(
19+
"The code executed for %.2F seconds. But the timeout is %d " .
20+
"seconds. The last %.2F seconds were executed outside the lock.",
21+
$elapsed_time,
22+
$timeout,
23+
$elapsed_time - $timeout
24+
);
25+
26+
return new self($message);
27+
}
1628
}

classes/mutex/FlockMutex.php

Lines changed: 134 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22

33
namespace malkusch\lock\mutex;
44

5+
use malkusch\lock\exception\ExecutionOutsideLockException;
56
use malkusch\lock\exception\LockAcquireException;
67
use malkusch\lock\exception\LockReleaseException;
8+
use malkusch\lock\exception\TimeoutException;
9+
use malkusch\lock\util\Loop;
10+
use malkusch\lock\util\PcntlTimeout;
711

812
/**
913
* Flock() based mutex implementation.
@@ -15,43 +19,165 @@
1519
*/
1620
class FlockMutex extends LockMutex
1721
{
18-
22+
const INFINITE_TIMEOUT = -1;
23+
24+
/**
25+
* @internal
26+
*/
27+
const STRATEGY_BLOCK = 1;
28+
29+
/**
30+
* @internal
31+
*/
32+
const STRATEGY_PCNTL = 2;
33+
34+
/**
35+
* @internal
36+
*/
37+
const STRATEGY_BUSY = 3;
38+
1939
/**
2040
* @var resource $fileHandle The file handle.
2141
*/
2242
private $fileHandle;
23-
43+
44+
/**
45+
* @var int
46+
*/
47+
private $timeout;
48+
49+
/**
50+
* @var int
51+
*/
52+
private $strategy;
53+
54+
/**
55+
* @var float|null
56+
*/
57+
private $acquired;
58+
2459
/**
2560
* Sets the file handle.
2661
*
2762
* @param resource $fileHandle The file handle.
2863
* @throws \InvalidArgumentException The file handle is not a valid resource.
2964
*/
30-
public function __construct($fileHandle)
65+
public function __construct($fileHandle, $timeout = self::INFINITE_TIMEOUT)
3166
{
3267
if (!is_resource($fileHandle)) {
3368
throw new \InvalidArgumentException("The file handle is not a valid resource.");
3469
}
70+
3571
$this->fileHandle = $fileHandle;
72+
$this->timeout = $timeout;
73+
$this->strategy = $this->determineLockingStrategy();
3674
}
37-
75+
76+
private function determineLockingStrategy()
77+
{
78+
if ($this->timeout == self::INFINITE_TIMEOUT) {
79+
return self::STRATEGY_BLOCK;
80+
}
81+
82+
if (PcntlTimeout::isSupported()) {
83+
return self::STRATEGY_PCNTL;
84+
}
85+
86+
return self::STRATEGY_BUSY;
87+
}
88+
3889
/**
39-
* @internal
90+
* @throws LockAcquireException
4091
*/
41-
protected function lock()
92+
private function lockBlocking()
4293
{
4394
if (!flock($this->fileHandle, LOCK_EX)) {
4495
throw new LockAcquireException("Failed to lock the file.");
4596
}
4697
}
47-
98+
4899
/**
49-
* @internal
100+
* @throws LockAcquireException
101+
* @throws TimeoutException
102+
*/
103+
private function lockPcntl()
104+
{
105+
$timebox = new PcntlTimeout($this->timeout);
106+
107+
$timebox->timeBoxed(function () {
108+
$this->lockBlocking();
109+
});
110+
}
111+
112+
/**
113+
* @throws TimeoutException
114+
* @throws LockAcquireException
115+
*/
116+
private function lockBusy()
117+
{
118+
$loop = new Loop($this->timeout);
119+
$loop->execute(function () use ($loop) {
120+
if ($this->acquireNonBlockingLock()) {
121+
$this->acquired = \microtime(true);
122+
$loop->end();
123+
}
124+
});
125+
}
126+
127+
/**
128+
* @return bool
129+
* @throws LockAcquireException
130+
*/
131+
private function acquireNonBlockingLock()
132+
{
133+
if (!flock($this->fileHandle, LOCK_EX | LOCK_NB, $wouldblock)) {
134+
if ($wouldblock) {
135+
/*
136+
* Another process holds the lock.
137+
*/
138+
return false;
139+
}
140+
throw new LockAcquireException("Failed to lock the file.");
141+
}
142+
return true;
143+
}
144+
145+
/**
146+
* @throws LockAcquireException
147+
* @throws TimeoutException
148+
*/
149+
protected function lock()
150+
{
151+
switch ($this->strategy) {
152+
case self::STRATEGY_BLOCK:
153+
$this->lockBlocking();
154+
return;
155+
case self::STRATEGY_PCNTL:
156+
$this->lockPcntl();
157+
return;
158+
case self::STRATEGY_BUSY:
159+
$this->lockBusy();
160+
return;
161+
}
162+
163+
throw new \RuntimeException("Unknown strategy '{$this->strategy}'.'");
164+
}
165+
166+
/**
167+
* @throws LockReleaseException
168+
* @throws ExecutionOutsideLockException
50169
*/
51170
protected function unlock()
52171
{
53172
if (!flock($this->fileHandle, LOCK_UN)) {
54173
throw new LockReleaseException("Failed to unlock the file.");
55174
}
175+
176+
if ($this->acquired !== null) {
177+
$elapsed_time = \microtime(true) - $this->acquired;
178+
if ($elapsed_time > $this->timeout) {
179+
throw ExecutionOutsideLockException::create($elapsed_time, $this->timeout);
180+
}
181+
}
56182
}
57183
}

classes/mutex/SpinlockMutex.php

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -77,16 +77,9 @@ protected function lock()
7777

7878
protected function unlock()
7979
{
80-
$elapsed = microtime(true) - $this->acquired;
81-
if ($elapsed >= $this->timeout) {
82-
$message = sprintf(
83-
"The code executed for %.2F seconds. But the timeout is %d " .
84-
"seconds. The last %.2F seconds were executed outside the lock.",
85-
$elapsed,
86-
$this->timeout,
87-
$elapsed - $this->timeout
88-
);
89-
throw new ExecutionOutsideLockException($message);
80+
$elapsed_time = microtime(true) - $this->acquired;
81+
if ($elapsed_time > $this->timeout) {
82+
throw ExecutionOutsideLockException::create($elapsed_time, $this->timeout);
9083
}
9184

9285
/*

classes/util/PcntlTimeout.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ public function __construct($timeout)
5555
* @throws TimeoutException Running the code timed out
5656
* @throws LockAcquireException Installing the timeout failed
5757
*/
58-
public function timeBoxed($code)
58+
public function timeBoxed(callable $code)
5959
{
6060
$signal = pcntl_signal(SIGALRM, function () {
6161
throw new TimeoutException("Timed out");
@@ -86,6 +86,11 @@ public function timeBoxed($code)
8686
*/
8787
public static function isSupported()
8888
{
89-
return extension_loaded("pcntl");
89+
return
90+
PHP_SAPI === "cli" &&
91+
extension_loaded("pcntl") &&
92+
function_exists("pcntl_alarm") &&
93+
function_exists("pcntl_signal") &&
94+
function_exists("pcntl_signal_dispatch");
9095
}
9196
}

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"ext-pdo_mysql": "*",
3636
"ext-pdo_sqlite": "*",
3737
"ext-redis": "*",
38+
"eloquent/liberator": "^2.0",
3839
"johnkary/phpunit-speedtrap": "^1.0",
3940
"kriswallsmith/spork": "^0.3",
4041
"mikey179/vfsStream": "^1.5.0",

tests/mutex/MutexConcurrencyTest.php

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace malkusch\lock\mutex;
44

5+
use Eloquent\Liberator\Liberator;
56
use Predis\Client;
67
use Redis;
78
use ezcSystemInfo;
@@ -87,6 +88,7 @@ private function fork($concurrency, callable $code)
8788
*
8889
* @test
8990
* @dataProvider provideTestHighContention
91+
* @slowThreshold 1000
9092
*/
9193
public function testHighContention(callable $code, callable $mutexFactory)
9294
{
@@ -203,8 +205,9 @@ function ($timeout = 3) use ($dsn, $user) {
203205
* @param callable $mutexFactory The Mutex factory.
204206
* @test
205207
* @dataProvider provideMutexFactories
208+
* @slowThreshold 1000
206209
*/
207-
public function testSerialisation(callable $mutexFactory)
210+
public function testExecutionIsSerializedWhenLocked(callable $mutexFactory)
208211
{
209212
$timestamp = microtime(true);
210213

@@ -233,7 +236,23 @@ public function provideMutexFactories()
233236
$file = fopen($this->path, "w");
234237
return new FlockMutex($file);
235238
}],
236-
239+
240+
"flockWithTimoutPcntl" => [function ($timeout = 3) {
241+
$file = fopen($this->path, "w");
242+
$lock = Liberator::liberate(new FlockMutex($file, $timeout));
243+
$lock->stategy = FlockMutex::STRATEGY_PCNTL;
244+
245+
return $lock;
246+
}],
247+
248+
"flockWithTimoutBusy" => [function ($timeout = 3) {
249+
$file = fopen($this->path, "w");
250+
$lock = Liberator::liberate(new FlockMutex($file, $timeout));
251+
$lock->stategy = FlockMutex::STRATEGY_BUSY;
252+
253+
return $lock;
254+
}],
255+
237256
"semaphore" => [function ($timeout = 3) {
238257
$semaphore = sem_get(ftok($this->path, "b"));
239258
$this->assertTrue(is_resource($semaphore));

tests/mutex/MutexTest.php

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace malkusch\lock\mutex;
44

5+
use Eloquent\Liberator\Liberator;
56
use org\bovigo\vfs\vfsStream;
67
use Predis\Client;
78
use Redis;
@@ -24,7 +25,12 @@
2425
class MutexTest extends \PHPUnit_Framework_TestCase
2526
{
2627
const TIMEOUT = 4;
27-
28+
29+
public static function setUpBeforeClass()
30+
{
31+
vfsStream::setup("test");
32+
}
33+
2834
/**
2935
* Provides Mutex factories.
3036
*
@@ -44,8 +50,24 @@ public function provideMutexFactories()
4450
}],
4551

4652
"FlockMutex" => [function () {
47-
vfsStream::setup("test");
48-
return new FlockMutex(fopen(vfsStream::url("test/lock"), "w"));
53+
$file = fopen(vfsStream::url("test/lock"), "w");
54+
return new FlockMutex($file);
55+
}],
56+
57+
"flockWithTimoutPcntl" => [function () {
58+
$file = fopen(vfsStream::url("test/lock"), "w");
59+
$lock = Liberator::liberate(new FlockMutex($file, 3));
60+
$lock->stategy = FlockMutex::STRATEGY_PCNTL;
61+
62+
return $lock;
63+
}],
64+
65+
"flockWithTimoutBusy" => [function ($timeout = 3) {
66+
$file = fopen(vfsStream::url("test/lock"), "w");
67+
$lock = Liberator::liberate(new FlockMutex($file, 3));
68+
$lock->stategy = FlockMutex::STRATEGY_BUSY;
69+
70+
return $lock;
4971
}],
5072

5173
"SemaphoreMutex" => [function () {

tests/mutex/SpinlockMutexTest.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ public function testAcquireTimesOut()
7272
*/
7373
public function testExecuteTooLong()
7474
{
75+
/** @var SpinlockMutex|\PHPUnit_Framework_MockObject_MockObject $mutex */
7576
$mutex = $this->getMockForAbstractClass(SpinlockMutex::class, ["test", 1]);
7677
$mutex->expects($this->any())->method("acquire")->willReturn(true);
7778
$mutex->expects($this->any())->method("release")->willReturn(true);
@@ -83,7 +84,7 @@ public function testExecuteTooLong()
8384
);
8485

8586
$mutex->synchronized(function () {
86-
sleep(1);
87+
usleep(1e6 + 1);
8788
});
8889
}
8990

@@ -107,7 +108,7 @@ public function testExecuteBarelySucceeds()
107108
* Tests failing to release a lock.
108109
*
109110
* @test
110-
* @expectedException malkusch\lock\exception\LockReleaseException
111+
* @expectedException \malkusch\lock\exception\LockReleaseException
111112
*/
112113
public function testFailReleasingLock()
113114
{

0 commit comments

Comments
 (0)