-
Notifications
You must be signed in to change notification settings - Fork 7.9k
Allow fiber switching in destructors #13460
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
It may have called their destructors, but the objects are still having their data. Also shutdown tasks like "send a http call and await it" are still possible to do - just with fiber switching. Being in shutdown just means that the destructor itself must not suspend, but it may start other fibers and I don't see why that would be forbidden. |
What I had in mind is that these objects may not be fully functional at this point because they may have closed critical resources or transitioned to some invalid state. In particular, the destructor of Fibers terminates them and they can not be resumed after that. The following program throws because the Fiber is destroyed before class C {
public static $c;
public function __construct(public Fiber $f) {}
public function __destruct() {
$this->f->resume();
}
}
$f = new Fiber(function () {
Fiber::suspend();
});
$f->start();
C::$c = new C($f);
Agreed. Also I didn't realize that the shutdown sequence always happens in the main "fiber", so we can safely unblock switching without risking suspension of the shutdown sequence. I've updated the PR to allow switching during shutdown. |
Looks like this works! |
All good on my side with this PR. I figured out the infinite loop, it was on my side. |
I believe suspending fibers in destructors might make memory corruption at a distance more probable. We've had many issues in the past where destructors or error handlers would lead to segfaults if they would modify memory currently referenced through indirect operands. For example: Zend/tests/in-de-crement/oss-fuzz-61469_binop_dynamic_property_unset_error_handler.phpt: class C {
function errorHandle() {
unset($this->a);
}
}
$c = new C;
set_error_handler([$c,'errorHandle']);
$c->a += 5;
var_dump($c->a); (The same issue goes for Such errors are unlikely to happen in real code, as destructors are usually short and don't rely on global values. For the error to occur, the handler specifically needs to modify values that the current opcode handler is relying on. However, when suspending a fiber, suddenly all code that is executed until the fiber is resumed is running "in the destructor". For example, if Many of those were fixed by delaying the destructor call. Maybe all of them have been resolved. I'm just pointing this out because they may be hard to diagnose. |
I would prefer not to do this without a big reason.
In GC destructors are called before the memory is actually freed, switching to another fiber at this moment will lead to additional memory consumption grow. In any case, destructors just can't be 100% compatible with GC their selves. |
The big reason are surprising fatal errors in production code because something deep down a stacktrace within a destructor happened to do a small I/O task which naturally causes a fiber switch. |
I see and understand your intention (it makes sense). In case of transfer in destructor during GC, the gc stays in the |
If a destructor transfers to an other Fiber via start() or resume(), this is what will happen. This is no different than a destructor calling an other function, in this context. If a destructor suspends, it actually transfers the control to the GC (gc_call_destructors_in_fiber). In this case we proceed to execute other destructors, and garbage can be freed as soon as all destructors have either terminated or suspended. Objects with destructors may be collected during the next run, as before. |
It's clear when the function returns buck, but unclear when the fibers would transfer the control.
Do you mean |
If a destructor suspends, it immediately transfers the control back to the GC. That's because this PR runs destructors in a separate fiber. Before this PR, this is what would happen in case a destructor suspends:
After this PR, this is what would happen:
When It's up to the application to ensure that suspended fibers eventually resume. To handle the case where an application doesn't reference a fiber anymore, I've updated the branch to ensure that the GC doesn't hold on suspended dtor fibers, so that they can be collected in case the application doesn't reference them.
Yes. The |
Hello, is there any chance this PR could be merged for 8.4? Without it, we can't really enjoy using fibers as seamlessly as they were supposed to work (no blue/green functions). I would be happy to use fibers more in e.g. Symfony but this issue with destructors is preventing me from doing so. |
@dstogov do you have objections about this change after my latest comment? |
@arnaud-lb I can't follow your design decisions (what is expected and what is not) through analysing the code changes and reading comments. It would be great to have some design document. I mainly afraid about the GC changes. The following example demonstrates that switching Fiber in a destructor prevents execution of the rest of the destructor code. Is this intended? For me this looks wrong. <?php
class Data {
public $n;
public function __construct($n) {
$this->n = $n;
}
public function dump() {
echo "dump(" . $this->n . ")\n";
}
public function __destruct() {
echo "dtor(" . $this->n . ")\n";
$this->n = null;
}
}
class Foo {
public $next;
public $data;
public function __destruct() {
$this->data->dump();
Fiber::suspend();
$this->data->dump(); // this one is never called
}
}
$fiber = new Fiber(function () {
call_user_func(function () {
$a = new Foo;
$b = new Foo;
$a->next = $b;
$b->next = $a;
$a->data = new Data("a");
$b->data = new Data("b");
});
var_dump(gc_collect_cycles());
});
$fiber->start();
var_dump(gc_collect_cycles());
?> |
@dstogov I wrote this: https://docs.google.com/document/d/15xNb_X7gF3B_X-7jv1hXWeFWkW2aROmT_WBHQrKKPLM/edit. I've tried to clarify what I could, but I'm not sure what to add. Let me know if you want me to clarify something or to expand some part.
This is intended, and here is why I think this is not an issue: From the GC point of view: The object's refcount is increased before executing the dtor, and will only be decreased when the dtor returns (not when it suspends, because DELREF() does not execute until it returns) : Lines 1834 to 1837 in f15cd75
As a result, unless the dtor resumes and returns during the current GC run, the object will be considered resurrected by the GC, and will not be freed in this run. The object remains in the buffer and will be scanned again during next run. I use gc_check_possible_root() on the obj when the dtor resumes, in case it resumed after more than one run. This is safe, as it's not different from an object resurrecting itself in a destructor by creating more references to itself. From the application point of view: If a suspended dtor is not resumed until the application terminates, the rest of the dtor may never be executed. The enclosing Fiber will be gracefully terminated, and only finally blocks will be executed. It is the responsibility of the application to ensure that suspended Fibers are eventually resumed. In the typical use-case, Fibers are managed by an event loop in a library like Amphp, and the event loop will not return until all Fibers are terminated, so this should not be an issue, and the burden is not on the user. I added a pseudo event loop to your example: https://gist.github.com/arnaud-lb/db60218c3333913d66f19f3d7c599720. In this case the destructors are allowed to return. |
May be I don't understand something. Let an object destructor calls some "asynchronous" IO routine that calls Fiber::suspend(), assuming it is suspending the application Fiber where it was started in and that it's going to continue the IO, when data is ready and fiber is resumed, but instead the GC Fiber is suspended, GC destroys this object, IO can't be resumed and the rest of the destructor code is never executed. Now let GC is called at non-deterministic moment and some destructors executed partially. Adding |
See the example: <?php
class Foo {
public $next;
public function __destruct() {
try {
echo "1\n";
Fiber::suspend();
echo "2\n";
} finally {
echo "3\n";
}
}
}
class Bar {
public $next;
}
/* This works */
$fiber = new Fiber(function () {
call_user_func(function () {
$a = new Foo;
});
});
$fiber->start();
$fiber->resume();
echo "4\n\n";
/* This doesn't work */
$fiber = new Fiber(function () {
call_user_func(function () {
$a = new Foo;
$b = new Bar;
$a->next = $b;
$b->next = $a;
});
gc_collect_cycles();
});
$fiber->start();
$fiber->resume();
echo "4\n";
?> The output:
The only difference between (1) and (2) that the GC was involved. |
@dstogov Your second example fails because the fiber referenced by If the destructor is not resumed, the unresumed fiber is destroyed and cleaned up at shutdown like any other unresumed fiber at shutdown. This is not a unique or unexpected behavior. I'd like to get this merged for 8.4 if possible. The inability to suspend in destructors has been a long-standing issue for users of Fibers, and now is a blocker for wider adoption in frameworks like Symfony. |
In both examples an instance of the class Foo is destroyed in a Fiber. In first case because of reference-counting, in second case by GC. The behaviour is completely different. The attempt to resume the destructor work at first case and not at the second. I can't see this as a right behavior. |
In order for the behavior to be identical, we'd need to run every destructor within a fiber, so there would be a fiber switch incurred on every destructor call. I doubt this is an acceptable overhead. I think the behavior difference here may not be necessarily intuitive at first, but is expected when using fibers.
The suspend call in the second example is not within the fiber created in the call to |
I think that an async IO routine would not make assumptions about the current Fiber, and would use function io_read($fd) {
$fiber = Fiber::getCurrent();
EventLoop::onReadable($fd, fn () => $fiber->resume());
Fiber::suspend();
return fread($fd);
}
function __destruct() {
echo io_read($fd), "\n";
} When using fibers like this, the proposed change is safe, as the Fiber from which __destruct is called does not matter. I believe this is what AmpPHP and related libraries do. |
@arnaud-lb OK. I see that this works for the desired use-case. I don't support this (I expect related troubles), but I won't object against this any more. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After asking for feedback in the slack chat a couple times, there were no objections to merging this PR.
…olas-grekas) This PR was merged into the 7.2 branch. Discussion ---------- [HttpClient] Add support for amphp/http-client v5 | Q | A | ------------- | --- | Branch? | 7.2 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | #52008 | License | MIT This PR adds support for amphp/http-client version 5 as a transport for our HttpClient component. It started as a draft at nicolas-grekas#43, which helped spot that PHP is missing the capability to suspend fibers in destructors. This was reported as php/php-src#11389 and is being fixed at php/php-src#13460. Since the fix for php-src is going to land on PHP 8.4, using amphp/http-client version 5 will require php >= 8.4. The implementation duplicates the one we have for v4 with the needed changes to use the v5 API. The difference are not big in size of code, but they're very low level (generators vs fibers). That would be quite useless to factor IMHO. Commits ------- a5fb61c [HttpClient] Add support for amphp/http-client v5
See #11389:
This PR allows fiber switching in destructors.
I assume that the reason Fiber for which switching is currently not allowed in destructors is that it could suspend GC runs indefinitely and preclude any future GC run.
This PR avoids this issue by implementing the second part of the solution proposed by @trowski here: The GC calls destructors in a Fiber, such that they can suspend without suspending the GC itself. If the Fiber is suspended, a new one is started to execute the remaining destructors. They are still executed during GC (when
GC_G(gc_active)
is1
) as they used to, so the changes are minimal (the GC algorithm is not affected). One difference is that they may not terminate before the GC ends. Moving destructor calls after GC (when other GC runs can execute) doesn't appear to be necessary and would add complexity (the GC buffer would be accessed concurrently).One caveat is that the engine can not guarantee that suspended destructors eventually terminate. In particular, the shutdown sequence will destroy all live objects (including fibers) in an unspecified order. This forcefully terminates any suspended destructor, executing only their finally blocks.
The PR does not allow Fiber switching in the shutdown sequence yet. (Edit: updated PR to allow switching during shutdown.) It may be pointless since nothing can be relied on at this point: The sequence may have destroyed all other objects and fibers.
We don't need to call destructors in a Fiber if the GC itself was invoked in the main context, because destructors can not suspend the GC in this case. So when there is no
EG(active_fiber)
, we can call destructors directly.Synthetic benchmarks show no significant performance regressions when comparing this branch to master.
cc @nicolas-grekas @trowski