While debugging some very odd memory issues in a live application, I noticed that the StreamSelectLoop does not immediately free all related memory when a timer is cancelled. Let's not call this a "memory leak", because memory was eventually freed, but this clearly caused some unexpected and significant memory growth.
In many common programs, adding timeouts to operations is a very fundamental operation. As such, it's common to end up with hundred or thousands of timers which most of the time get cancelled very frequently as a successful execution will quickly cancel outstanding timeouts.
I've used the following script to demonstrate unreasonable memory growth:
<?php
use React\EventLoop\Factory;
require __DIR__ . '/../vendor/autoload.php';
$loop = Factory::create();
$loop->addPeriodicTimer(0.00001, function () use ($loop) {
$timer = $loop->addTimer(60, function () { });
$loop->cancelTimer($timer);
});
$loop->addPeriodicTimer(1.0, function () use ($loop) {
echo memory_get_usage() . PHP_EOL;
});
$loop->run();
Running this on the current master branch, this peaked at around 320 MB of memory on my system (YMMV). After applying this patch, this script reports a constant memory consumption of around 0.7 MB.
The original implementation internally relied on a SplPriorityQueue for fast insertions of new timers, but this class lacks access to actually remove timers. This means that they would actually stay in the queue until they were originally supposed to fire.
This was apparently done for performance reasons, so this patch includes some thorough performance tests. The result here is that adding a large number of timers at the same time shows a significant performance improvement (time php examples/92-benchmark-timers.php 20000 from 15s down to 2s). Waiting for a large number of timers to fire one after another does not show a noticeable impact (time php examples/94-benchmark-timers-delay.php 20000 both at around 2.5s).