-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy paththread_pool.c
More file actions
2195 lines (1850 loc) · 76.2 KB
/
Copy paththread_pool.c
File metadata and controls
2195 lines (1850 loc) · 76.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
+----------------------------------------------------------------------+
| Copyright (c) The PHP Group |
+----------------------------------------------------------------------+
| This source file is subject to version 3.01 of the PHP license, |
| that is bundled with this package in the file LICENSE, and is |
| available through the world-wide-web at the following url: |
| https://www.php.net/license/3_01.txt |
| If you did not receive a copy of the PHP license and are unable to |
| obtain it through the world-wide-web, please send a note to |
| license@php.net so we can mail you a copy immediately. |
+----------------------------------------------------------------------+
| Author: Edmond |
+----------------------------------------------------------------------+
*/
#include "thread_pool.h"
#include "thread_pool_arginfo.h"
#include "thread.h"
#include "async_API.h"
#include "exceptions.h"
#include "php_async.h"
#include "scheduler.h"
#include "thread_channel.h"
#include "future.h"
#include "zend_common.h"
#include "zend_interfaces.h"
#include "zend_exceptions.h"
#include "zend_closures.h"
zend_class_entry *async_ce_thread_pool = NULL;
zend_class_entry *async_ce_thread_pool_exception = NULL;
static zend_object_handlers thread_pool_handlers;
#define METHOD(name) PHP_METHOD(Async_ThreadPool, name)
#define THIS_POOL() (ASYNC_THREAD_POOL_FROM_OBJ(Z_OBJ_P(ZEND_THIS))->pool)
///////////////////////////////////////////////////////////
/// Pool refcount
///////////////////////////////////////////////////////////
static void thread_pool_destroy(async_thread_pool_t *pool);
static void thread_pool_close(async_thread_pool_t *pool);
static void thread_pool_drain_tasks(async_thread_pool_t *pool, bool reject, zend_object *reject_with);
typedef struct _pool_worker_cancel_s pool_worker_cancel_t;
/* What became of a task the worker took off the channel. CANCELLED means the
* consumer asked for it before the coroutine existed: nothing ran, and the
* caller settles the task itself. */
typedef enum {
POOL_TASK_SPAWNED,
POOL_TASK_SPAWN_FAILED,
POOL_TASK_CANCELLED
} pool_task_spawn_t;
static pool_task_spawn_t thread_pool_spawn_task_coroutine(
async_thread_pool_t *pool, zend_async_scope_t *pool_scope,
zval *callable,
zend_fcall_info *fci, zend_fcall_info_cache *fcc,
zval *params, uint32_t param_count,
async_thread_snapshot_t *snapshot, zend_future_shared_state_t *state,
int32_t *active_count, zend_async_trigger_event_t *slot_event,
pool_worker_cancel_t *cancel);
static pool_worker_cancel_t *pool_worker_cancel_create(void);
static void pool_worker_cancel_destroy(pool_worker_cancel_t *cancel);
/* `entry` is the coroutine serving the state, or POOL_TASK_PREPARING while it
* does not exist yet. Tracking and binding go together, so that "listed here"
* and "bound to this worker" stay the same statement. */
static bool pool_worker_cancel_track(
pool_worker_cancel_t *cancel, zend_future_shared_state_t *state, void *entry);
static void pool_worker_cancel_forget(
pool_worker_cancel_t *cancel, zend_future_shared_state_t *state);
///////////////////////////////////////////////////////////
/// Worker entry — C handler called inside spawned thread
///////////////////////////////////////////////////////////
/**
* @brief Worker loop — receives tasks from channel, executes, completes.
*
* ctx = async_thread_pool_t* (shared pool).
*
* Each task is a 4-element array:
* [0] kind — TASK_KIND_CLOSURE | TASK_KIND_INTERNAL (long)
* [1] payload_a — closure: snapshot_ptr ; internal: handler_ptr
* [2] payload_b — closure: args_array ; internal: ctx_ptr
* [3] state_ptr — shared future state (long)
*
* The discriminator lets the worker dispatch either to the PHP-closure
* code path (snapshot + zend_call_function) or to a C-handler call
* (handler(event, ctx)) without changing the channel transport.
*/
#define TASK_SLOT_KIND 0
#define TASK_SLOT_PAYLOAD_A 1
#define TASK_SLOT_PAYLOAD_B 2
#define TASK_SLOT_STATE 3
#define TASK_KIND_CLOSURE 0
#define TASK_KIND_INTERNAL 1
static zend_function worker_root_function = { ZEND_INTERNAL_FUNCTION };
/* Build a ThreadTransferException carrying the current bailout's message.
* Used when the worker observed a graceful exit()/die() (unwind-exit token) or a
* fatal-error bailout: the pool delivers this to awaiters instead of re-raising
* zend_bailout() or passing the token to reject() — either crashes the worker
* fiber, which can't transfer a non-throwable exit token. */
static zend_object *thread_pool_bailout_exception(void)
{
const zend_string *msg = PG(last_error_message);
return async_new_exception(async_ce_thread_transfer_exception, "%s",
msg != NULL ? ZSTR_VAL(msg)
: "ThreadPool worker terminated via exit() or a fatal error");
}
/* Build a clean ThreadTransferException carrying another exception's message.
* Used for errors thrown deep in the cross-thread transfer machinery (e.g.
* "Cannot load transferred object"): that Error's full object graph — its
* backtrace reaches into worker-local load state — can crash the awaiter when
* deep-copied to the parent thread, so we re-ship only its message text. The
* copy happens inside async_new_exception while `src` is still alive. */
static zend_object *thread_pool_wrap_transfer_error(zend_object *src)
{
zval rv;
const zval *msg = zend_read_property_ex(src->ce, src, ZSTR_KNOWN(ZEND_STR_MESSAGE), 1, &rv);
return async_new_exception(async_ce_thread_transfer_exception, "%s",
(msg != NULL && Z_TYPE_P(msg) == IS_STRING) ? Z_STRVAL_P(msg) : "thread transfer failed");
}
/* Report the bootloader's uncaught exception through this worker's own error
* stream — display_errors and error_log, the same route an uncaught exception
* takes in any other request — and consume EG(exception).
*
* The rejection of pending tasks is the pool's only other channel for a failed
* bootloader, and it reaches nobody when the pool serves as a set of long-lived
* workers (an HTTP server submits one task per worker and never awaits it) or
* when the failure happens before anything is submitted. Without this the whole
* pool dies with no diagnostic at all. */
static void thread_pool_report_boot_failure(void)
{
if (EG(exception) == NULL) {
return;
}
/* E_ERROR is reported, not raised: zend_exception_error adds E_DONT_BAIL,
* so the worker keeps running and reaches the reject/close path below. */
zend_exception_error(EG(exception), E_ERROR);
zend_clear_exception();
}
/* task_channel is swapped by reload() and read cross-thread on bailout paths —
* always go through the atomic load. */
#define POOL_TASK_CHANNEL(pool) \
((async_thread_channel_t *) zend_atomic_ptr_load_ex(&(pool)->task_channel))
/* Record (once) the bootloader-failure message on the pool, before its channel
* is closed, so a submit() that races the close reports the real reason instead
* of a generic closed-pool error. Guarded by the channel mutex; first failing
* worker wins. */
static void thread_pool_record_bootloader_error(async_thread_pool_t *pool, zend_object *ex)
{
async_thread_channel_t *channel = POOL_TASK_CHANNEL(pool);
if (channel == NULL) {
return;
}
zval rv;
const zval *msg = zend_read_property_ex(ex->ce, ex, ZSTR_KNOWN(ZEND_STR_MESSAGE), 1, &rv);
const char *text = (msg != NULL && Z_TYPE_P(msg) == IS_STRING)
? Z_STRVAL_P(msg) : "ThreadPool bootloader failed";
ASYNC_MUTEX_LOCK(channel->mutex);
if (pool->bootloader_error == NULL) {
pool->bootloader_error = pestrdup(text, 1);
}
ASYNC_MUTEX_UNLOCK(channel->mutex);
}
/* Throw the reason a submit failed against a closed pool: the real bootloader
* error if a worker recorded one before closing the channel, otherwise the
* given generic message. A pending generic channel-closed exception is replaced
* by the bootloader error so the awaiter sees the true cause, not the symptom. */
static void thread_pool_throw_closed(async_thread_pool_t *pool, const char *fallback)
{
if (pool->bootloader_error != NULL) {
if (EG(exception)) {
zend_clear_exception();
}
zend_throw_exception(async_ce_thread_transfer_exception, pool->bootloader_error, 0);
return;
}
if (!EG(exception)) {
zend_throw_exception(async_ce_thread_pool_exception, fallback, 0);
}
}
/* Guard for every enqueue path. An open pool with no live worker still accepts
* a task into the channel, where nothing receives it and its future never
* settles. Returns true after throwing, so the caller stops; returns false
* while at least one worker can serve. */
static bool thread_pool_throw_if_no_workers(async_thread_pool_t *pool)
{
if (EXPECTED(zend_atomic_int_load(&pool->live_workers) > 0)) {
return false;
}
zend_throw_exception(async_ce_thread_pool_exception,
"ThreadPool has no live worker threads", 0);
return true;
}
/* event is always NULL for pool workers (started via ZEND_ASYNC_START_THREAD) */
/* Per-spawn worker context: the cohort channel is captured SYNCHRONOUSLY at
* spawn time (in start_worker), not read lazily here — so a worker started by
* reload N stays bound to that reload's channel even if reload N+1 swaps
* pool->task_channel before this thread runs. Freed by the worker on exit. */
typedef struct {
async_thread_pool_t *pool;
async_thread_channel_t *channel;
} thread_pool_worker_ctx_t;
/* The answer for a task that never ran, and the one the drain path already
* gives, so a cancelled task reads the same whichever side dropped it. */
static void thread_pool_reject_cancelled(zend_future_shared_state_t *state)
{
zend_object *cancelled = async_new_exception(
async_ce_cancellation_exception, "ThreadPool task was cancelled before execution");
async_future_shared_state_reject(state, cancelled);
OBJ_RELEASE(cancelled);
}
/* Own function so the handler's zend_try isn't nested (GCC -Wmaybe-uninitialized). */
static bool thread_pool_call_guarded(zend_fcall_info *fci, zend_fcall_info_cache *fcc)
{
volatile bool bailed = false;
zend_try {
zend_call_function(fci, fcc);
} zend_catch {
bailed = true;
} zend_end_try();
return bailed;
}
static bool thread_pool_suspend_guarded(void)
{
volatile bool bailed = false;
zend_try {
ZEND_ASYNC_SUSPEND();
} zend_catch {
bailed = true;
} zend_end_try();
return bailed;
}
static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *ctx)
{
thread_pool_worker_ctx_t *wc = (thread_pool_worker_ctx_t *) ctx;
async_thread_pool_t *pool = wc->pool;
/* This worker's cohort channel. reload() gives fresh workers a NEW channel
* and closes this one, so an old worker leaves its loop when receive()
* returns false on the closed channel. */
async_thread_channel_t *channel = wc->channel;
int bailout = 0;
/* Per-worker pool scope: child of worker's main scope. Pinned for the
* worker's lifetime; each spawned task lives in its own child scope of
* this one (see thread_pool_spawn_task_coroutine). Created lazily, only
* when coroutine_mode is enabled — sync workers don't need it. */
zend_async_scope_t *pool_scope = NULL;
/* Concurrency accounting (pool->concurrency > 0 only). Worker parks on
* slot_event at the limit. Volatile: assigned in the try, read by the
* bailout handler below, so it must survive the longjmp. */
int32_t active_count = 0;
zend_async_trigger_event_t * volatile slot_event = NULL;
/* Cancellation mailbox of this worker, created on its first closure task and
* used by both modes. Volatile for the same reason as slot_event: the
* bailout handler below reads it after a longjmp. */
pool_worker_cancel_t * volatile worker_cancel = NULL;
ZEND_ASSERT(event == NULL);
/* Create a fake internal frame so EG(current_execute_data) != NULL.
* Without this, zend_throw_exception triggers bailout because it thinks
* there is no PHP stack to catch the exception. */
zend_execute_data fake_frame = {0};
fake_frame.func = &worker_root_function;
fake_frame.prev_execute_data = EG(current_execute_data);
EG(current_execute_data) = &fake_frame;
zend_try {
ZEND_ASYNC_SCHEDULER_INIT();
if (UNEXPECTED(EG(exception))) {
zend_exception_error(EG(exception), E_WARNING);
zend_clear_exception();
goto done;
}
/* Bootloader — run once per worker before entering the receive loop.
* On failure we fail the entire pool: close the channel and reject all
* pending submissions. Other workers will then exit on next recv(). */
if (pool->bootloader_snapshot != NULL) {
zval boot_callable, boot_retval;
ZVAL_UNDEF(&boot_callable);
ZVAL_UNDEF(&boot_retval);
async_thread_create_closure(&pool->bootloader_snapshot->entry, &boot_callable);
if (UNEXPECTED(EG(exception))) {
/* Bootloader transfer failed (e.g. a $this-bound bootloader whose
* class isn't defined on the worker). Re-ship the error's message
* as a clean transfer exception (the raw Error's backtrace reaches
* into worker-local load state and crashes the awaiter if copied). */
zend_object *boot_ex = thread_pool_wrap_transfer_error(EG(exception));
thread_pool_report_boot_failure();
zval_ptr_dtor(&boot_callable);
thread_pool_record_bootloader_error(pool, boot_ex);
thread_pool_close(pool);
thread_pool_drain_tasks(pool, true, boot_ex);
OBJ_RELEASE(boot_ex);
goto done;
}
zend_fcall_info boot_fci;
zend_fcall_info_cache boot_fcc;
volatile bool boot_bailed = false;
if (zend_fcall_info_init(&boot_callable, 0, &boot_fci, &boot_fcc, NULL, NULL) == SUCCESS) {
boot_fci.retval = &boot_retval;
boot_bailed = thread_pool_call_guarded(&boot_fci, &boot_fcc);
}
zval_ptr_dtor(&boot_retval);
zval_ptr_dtor(&boot_callable);
if (boot_bailed
|| (EG(exception) != NULL
&& (zend_is_unwind_exit(EG(exception))
|| zend_is_graceful_exit(EG(exception))))) {
/* Bootloader called exit()/die() (unwind-exit token) or hit a fatal
* error (bailout). Convert into a transfer exception for every
* pending task instead of leaking the token through reject or
* re-raising zend_bailout(), either of which crashes the worker fiber.
* Nothing is reported here: a fatal error printed itself on the way
* to the bailout, and exit() is not an error. */
if (EG(exception) != NULL) {
zend_clear_exception();
}
zend_object *boot_ex = thread_pool_bailout_exception();
thread_pool_record_bootloader_error(pool, boot_ex);
thread_pool_close(pool);
thread_pool_drain_tasks(pool, true, boot_ex);
OBJ_RELEASE(boot_ex);
goto done;
}
if (UNEXPECTED(EG(exception))) {
/* Bootloader body threw — propagate the real exception to every
* pending task's awaiter instead of a generic cancellation. */
zend_object *boot_ex = EG(exception);
GC_ADDREF(boot_ex);
/* Takes over the reference EG(exception) held; ours keeps boot_ex
* alive for the reject below. */
thread_pool_report_boot_failure();
thread_pool_record_bootloader_error(pool, boot_ex);
thread_pool_close(pool);
thread_pool_drain_tasks(pool, true, boot_ex);
OBJ_RELEASE(boot_ex);
goto done;
}
}
zval task;
while (true) {
/* Concurrency gate: when at the limit, park via wait-only
* receive (result=NULL) suspending on both the channel and
* slot_event. Wakes on a free slot (dispose fires
* slot_event), new submit, OR close. */
while (pool->coroutine_mode && pool->concurrency > 0
&& active_count >= pool->concurrency) {
if (slot_event == NULL) {
slot_event = ZEND_ASYNC_NEW_TRIGGER_EVENT();
}
if (!channel->channel.receive(&channel->channel, NULL, &slot_event->base)) {
/* Channel closed — bail out. */
goto done;
}
if (UNEXPECTED(EG(exception))) {
zend_clear_exception();
goto done;
}
}
if (!channel->channel.receive(&channel->channel, &task, NULL)) {
break;
}
ZEND_ASSERT(Z_TYPE(task) == IS_ARRAY);
const zend_long kind =
Z_LVAL_P(zend_hash_index_find(Z_ARRVAL(task), TASK_SLOT_KIND));
zend_future_shared_state_t *state =
(zend_future_shared_state_t *)(uintptr_t) Z_LVAL_P(
zend_hash_index_find(Z_ARRVAL(task), TASK_SLOT_STATE));
zend_atomic_int_dec(&pool->base.pending_count);
zend_atomic_int_inc(&pool->base.running_count);
if (kind == TASK_KIND_INTERNAL) {
/* C-handler task — payload_a is handler ptr, payload_b is
* caller's pemalloc'd ctx. Handler frees ctx itself before
* returning; pool only takes ownership when dispatch fails
* (handled in drain path).
*
* Deliberately outside the cancellation protocol: the handler
* owns its ctx and frees it on the way out, so a task skipped
* here would leak it. A consumer that gives up on such a task
* raises the flag and nobody reads it. */
zend_thread_pool_internal_handler_t handler =
(zend_thread_pool_internal_handler_t)(uintptr_t) Z_LVAL_P(
zend_hash_index_find(Z_ARRVAL(task), TASK_SLOT_PAYLOAD_A));
void *handler_ctx = (void *)(uintptr_t) Z_LVAL_P(
zend_hash_index_find(Z_ARRVAL(task), TASK_SLOT_PAYLOAD_B));
/* event arg reserved for future extension (handler stashing
* result/exception). The shared_state's external event lives
* behind a private struct field; until a public accessor lands
* we pass NULL. Handler completes via return + EG(exception). */
handler(NULL, handler_ctx);
zend_atomic_int_dec(&pool->base.running_count);
zend_atomic_int_inc(&pool->base.completed_count);
if (EG(exception)) {
async_future_shared_state_reject(state, EG(exception));
zend_clear_exception();
} else {
zval undef;
ZVAL_UNDEF(&undef);
async_future_shared_state_complete(state, &undef);
}
async_future_shared_state_delref(state);
zval_ptr_dtor(&task);
continue;
}
/* TASK_KIND_CLOSURE — original PHP-closure path. */
async_thread_snapshot_t *snapshot =
(async_thread_snapshot_t *)(uintptr_t) Z_LVAL_P(
zend_hash_index_find(Z_ARRVAL(task), TASK_SLOT_PAYLOAD_A));
const zval *args_zv =
zend_hash_index_find(Z_ARRVAL(task), TASK_SLOT_PAYLOAD_B);
/* Dropped before it began. Read before the closure is unpacked, so a
* task nobody waits for costs a flag read instead of a materialized
* op_array and a copy of every captured value. */
if (UNEXPECTED(async_future_shared_state_is_cancel_requested(state))) {
zend_atomic_int_dec(&pool->base.running_count);
zend_atomic_int_inc(&pool->base.completed_count);
thread_pool_reject_cancelled(state);
async_future_shared_state_delref(state);
async_thread_snapshot_destroy(snapshot);
zval_ptr_dtor(&task);
continue;
}
/* Both modes cancel through this mailbox, so it is created here once
* rather than in each branch. A reactor that refuses the handle is a
* sick worker: reporting that on the task is louder than running it
* uncancellable, which would leave cancel() silently doing nothing
* exactly on the loaded worker where it is needed most. */
if (worker_cancel == NULL) {
worker_cancel = pool_worker_cancel_create();
if (UNEXPECTED(worker_cancel == NULL)) {
zend_atomic_int_dec(&pool->base.running_count);
zend_atomic_int_inc(&pool->base.completed_count);
/* Settled whether or not the reactor left a reason behind:
* a task that leaves here unsettled is an awaiter waiting
* forever. */
if (EG(exception)) {
async_future_shared_state_reject(state, EG(exception));
zend_clear_exception();
} else {
zend_object *failed = async_new_exception(
async_ce_thread_pool_exception,
"ThreadPool worker could not create its cancellation handle");
async_future_shared_state_reject(state, failed);
OBJ_RELEASE(failed);
}
async_future_shared_state_delref(state);
async_thread_snapshot_destroy(snapshot);
zval_ptr_dtor(&task);
continue;
}
}
zval callable, retval;
zval *params = NULL;
uint32_t param_count = 0;
ZVAL_UNDEF(&retval);
ZVAL_UNDEF(&callable);
/* Heap-copy op_array names so they outlive the arena on a fatal. */
async_thread_snapshot_materialize_entry(snapshot);
async_thread_create_closure(&snapshot->entry, &callable);
if (UNEXPECTED(EG(exception))) {
zend_atomic_int_dec(&pool->base.running_count);
zend_atomic_int_inc(&pool->base.completed_count);
async_future_shared_state_reject(state, EG(exception));
zend_clear_exception();
goto task_cleanup;
}
zend_fcall_info fci;
zend_fcall_info_cache fcc;
if (UNEXPECTED(zend_fcall_info_init(&callable, 0, &fci, &fcc, NULL, NULL) != SUCCESS)) {
zend_atomic_int_dec(&pool->base.running_count);
zend_atomic_int_inc(&pool->base.completed_count);
if (EG(exception)) {
async_future_shared_state_reject(state, EG(exception));
zend_clear_exception();
}
goto task_cleanup;
}
fci.retval = &retval;
param_count = zend_hash_num_elements(Z_ARRVAL_P(args_zv));
if (param_count > 0) {
params = emalloc(sizeof(zval) * param_count);
fci.params = params;
fci.param_count = param_count;
uint32_t i = 0;
zval *arg;
ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(args_zv), arg) {
ZVAL_COPY(¶ms[i++], arg);
} ZEND_HASH_FOREACH_END();
}
if (pool->coroutine_mode) {
/* Lazily create the worker's pool scope on first task. Pinned
* so a cancellation cascade from the worker's main scope
* doesn't free it out from under in-flight tasks. */
if (pool_scope == NULL) {
pool_scope = ZEND_ASYNC_NEW_SCOPE(ZEND_ASYNC_CURRENT_SCOPE);
if (UNEXPECTED(pool_scope == NULL)) {
zend_atomic_int_dec(&pool->base.running_count);
zend_atomic_int_inc(&pool->base.completed_count);
if (EG(exception)) {
async_future_shared_state_reject(state, EG(exception));
zend_clear_exception();
}
goto task_cleanup;
}
ZEND_ASYNC_SCOPE_SET_OWNER_PINNED(pool_scope);
}
/* Spawn task in a fresh child scope of pool_scope.
* Completion via pool_task_dispose. The slot pointers are
* passed only when concurrency is enforced so dispose
* skips the fire path entirely otherwise. */
int32_t *task_active = NULL;
zend_async_trigger_event_t *task_event = NULL;
if (pool->concurrency > 0) {
if (slot_event == NULL) {
slot_event = ZEND_ASYNC_NEW_TRIGGER_EVENT();
}
task_active = &active_count;
task_event = slot_event;
}
const pool_task_spawn_t spawned = thread_pool_spawn_task_coroutine(
pool, pool_scope, &callable, &fci, &fcc, params,
param_count, snapshot, state,
task_active, task_event, worker_cancel);
if (spawned == POOL_TASK_SPAWNED) {
if (pool->concurrency > 0) {
active_count++;
}
/* Ownership of params/snapshot/state transferred to
* coroutine. callable's closure was addref'd into
* fcall->fci.function_name — release our local ref.
* retval was UNDEF (real return goes into coroutine->result). */
zval_ptr_dtor(&callable);
zval_ptr_dtor(&retval);
zval_ptr_dtor(&task);
continue;
}
zend_atomic_int_dec(&pool->base.running_count);
zend_atomic_int_inc(&pool->base.completed_count);
if (spawned == POOL_TASK_CANCELLED) {
/* Asked for between the read above and the bind: nothing
* ran, so the answer is the one the early path gives. */
thread_pool_reject_cancelled(state);
goto task_cleanup;
}
/* Spawn failed — reject the future with the pending exception
* (if any) and free everything synchronously. */
if (EG(exception)) {
async_future_shared_state_reject(state, EG(exception));
zend_clear_exception();
}
goto task_cleanup;
}
/* Sync mode: run the body as a coroutine in a per-task nursery scope so
* Async\spawn() inside it lands there. Cancel + drain before freeing the
* snapshot so an un-awaited child can't outlive its arena. */
zend_coroutine_t *worker_coro = ZEND_ASYNC_CURRENT_COROUTINE;
zend_async_scope_t *task_scope =
worker_coro != NULL ? ZEND_ASYNC_NEW_SCOPE(ZEND_ASYNC_CURRENT_SCOPE) : NULL;
if (UNEXPECTED(task_scope == NULL)) {
zend_atomic_int_dec(&pool->base.running_count);
zend_atomic_int_inc(&pool->base.completed_count);
if (EG(exception)) {
async_future_shared_state_reject(state, EG(exception));
zend_clear_exception();
}
goto task_cleanup;
}
/* Nursery (NOT-safe): un-awaited children cancelled at exit. Pinned so
* it survives the drain; unpinned before RELEASE. */
ZEND_ASYNC_SCOPE_CLR_DISPOSE_SAFELY(task_scope);
ZEND_ASYNC_SCOPE_SET_OWNER_PINNED(task_scope);
zend_coroutine_t *body = ZEND_ASYNC_SPAWN_WITH(task_scope);
if (UNEXPECTED(body == NULL)) {
ZEND_ASYNC_SCOPE_CLR_OWNER_PINNED(task_scope);
ZEND_ASYNC_SCOPE_RELEASE(task_scope);
zend_atomic_int_dec(&pool->base.running_count);
zend_atomic_int_inc(&pool->base.completed_count);
if (EG(exception)) {
async_future_shared_state_reject(state, EG(exception));
zend_clear_exception();
}
goto task_cleanup;
}
/* Hand the call to the body; params ownership moves to it, snapshot
* stays ours to free after the drain. */
zend_fcall_t *fcall = ecalloc(1, sizeof(zend_fcall_t));
fcall->fci = fci;
fcall->fci_cache = fcc;
fcall->fci.param_count = param_count;
fcall->fci.params = params;
fcall->fci.retval = &body->result;
Z_TRY_ADDREF(fcall->fci.function_name);
body->fcall = fcall;
params = NULL;
/* The body is a coroutine here too, so a consumer that gives up
* reaches it the same way as in coroutine mode: the trigger fires
* while this worker sleeps on the body's event, the body unwinds
* with the cancellation, and it arrives below as body_error. */
if (UNEXPECTED(false == pool_worker_cancel_track(worker_cancel, state, body))) {
/* Asked for between the read at the top of the loop and this
* bind. The body has its fcall already, so it is cancelled
* rather than skipped; awaiting it below unwinds it at once. */
ZEND_ASYNC_CANCEL(body, NULL, false);
if (UNEXPECTED(EG(exception))) {
zend_clear_exception();
}
}
/* Await the body; its callback copies result/error into our waker. A
* fatal re-raises zend_bailout() out of the coroutine — caught here. */
bool body_bailed = false;
ZEND_ASYNC_WAKER_NEW(worker_coro);
zend_async_resume_when(worker_coro, &body->event, false,
zend_async_waker_callback_resolve, NULL);
body_bailed = thread_pool_suspend_guarded();
/* Off the trigger before anything can settle or free the state. */
pool_worker_cancel_forget(worker_cancel, state);
/* Decrement running and bump completed BEFORE notifying the awaiter
* via complete/reject — otherwise a coroutine waking from await()
* would observe stale running_count and a missing completed bump. */
zend_atomic_int_dec(&pool->base.running_count);
zend_atomic_int_inc(&pool->base.completed_count);
if (UNEXPECTED(body_bailed)) {
/* Fatal in the body: reject this task and tear the pool down. */
zend_async_waker_clean(worker_coro);
zend_object *bex = thread_pool_bailout_exception();
async_future_shared_state_reject(state, bex);
thread_pool_close(pool);
thread_pool_drain_tasks(pool, true, bex);
OBJ_RELEASE(bex);
ZEND_ASYNC_SCOPE_CLR_OWNER_PINNED(task_scope);
ZEND_ASYNC_SCOPE_RELEASE(task_scope);
zval_ptr_dtor(&callable);
zval_ptr_dtor(&retval);
async_thread_snapshot_destroy(snapshot);
async_future_shared_state_delref(state);
zval_ptr_dtor(&task);
break;
}
zend_object *body_error = NULL;
if (worker_coro->waker != NULL && worker_coro->waker->error != NULL) {
body_error = worker_coro->waker->error;
worker_coro->waker->error = NULL;
} else if (EG(exception) != NULL) {
body_error = EG(exception);
GC_ADDREF(body_error);
zend_clear_exception();
}
if (body_error != NULL) {
async_future_shared_state_reject(state, body_error);
OBJ_RELEASE(body_error);
} else if (worker_coro->waker != NULL
&& Z_TYPE(worker_coro->waker->result) != IS_UNDEF) {
async_future_shared_state_complete(state, &worker_coro->waker->result);
} else {
zval null_result;
ZVAL_NULL(&null_result);
async_future_shared_state_complete(state, &null_result);
}
zend_async_waker_clean(worker_coro);
/* Cancel + await un-awaited children before freeing the snapshot
* arena that backs their op_arrays. */
if (!ZEND_ASYNC_SCOPE_IS_CLOSED(task_scope)) {
ZEND_ASYNC_SCOPE_CANCEL(task_scope, NULL, false, false);
ZEND_ASYNC_SCOPE_AWAIT_AFTER_CANCELLATION(task_scope, worker_coro, NULL, NULL, NULL);
if (UNEXPECTED(EG(exception))) {
zend_clear_exception();
}
}
ZEND_ASYNC_SCOPE_CLR_OWNER_PINNED(task_scope);
ZEND_ASYNC_SCOPE_RELEASE(task_scope);
/* Drop the closure ref and free the snapshot. */
zval_ptr_dtor(&callable);
zval_ptr_dtor(&retval);
async_thread_snapshot_destroy(snapshot);
async_future_shared_state_delref(state);
zval_ptr_dtor(&task);
continue;
task_cleanup:
if (params) {
for (uint32_t i = 0; i < param_count; i++) {
zval_ptr_dtor(¶ms[i]);
}
efree(params);
}
zval_ptr_dtor(&retval);
zval_ptr_dtor(&callable);
async_thread_snapshot_destroy(snapshot);
async_future_shared_state_delref(state);
zval_ptr_dtor(&task);
}
if (EG(exception)) {
if (!instanceof_function(EG(exception)->ce, async_ce_thread_channel_exception)) {
zend_exception_error(EG(exception), E_WARNING);
}
zend_clear_exception();
}
done:
/* Cancel (if requested) and release pool_scope BEFORE AFTER_MAIN:
* unpinning + release lets the cascade disposal complete during
* the scheduler drain instead of leaking the scope. */
if (pool_scope != NULL) {
if (zend_atomic_int_load(&pool->cancel_requested)) {
/* is_safely=false overrides inherited DISPOSE_SAFELY —
* we want in-flight task coroutines to actually die. */
ZEND_ASYNC_SCOPE_CANCEL(pool_scope, NULL, false, false);
}
ZEND_ASYNC_SCOPE_CLR_OWNER_PINNED(pool_scope);
ZEND_ASYNC_SCOPE_RELEASE(pool_scope);
pool_scope = NULL;
}
/* Backstop for a retiring SYNC-mode worker: its tasks ran inline (no
* per-task coroutine scope), so any coroutine still alive is a straggler
* the bootloader spawned into the main scope (e.g. a DB-pool healthcheck
* timer) that the server-side scope drain never reaches. Its live reactor
* handle would keep AFTER_MAIN — and this worker's reload exit-token —
* hung forever, so force graceful shutdown to cancel it. Coroutine-mode
* pools instead drain their in-flight task coroutines below (see 077),
* so they must not take this path. */
if (!pool->coroutine_mode) {
start_graceful_shutdown();
}
ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN(false);
/* AFTER_MAIN drained all task coroutines (and ran their dispose,
* which may have fired slot_event). Now safe to release it. */
if (slot_event != NULL) {
slot_event->base.dispose(&slot_event->base);
slot_event = NULL;
}
pool_worker_cancel_destroy(worker_cancel);
worker_cancel = NULL;
} zend_catch {
bailout = 1;
} zend_end_try();
/* Restore execute_data */
EG(current_execute_data) = fake_frame.prev_execute_data;
if (bailout) {
/* Before anything that can bail out again — thread_pool_drain_tasks
* allocates and is not guarded — take every state off this worker's
* trigger. A state left pointing at it becomes a cross-thread
* use-after-free as soon as its consumer cancels. */
pool_worker_cancel_destroy(worker_cancel);
worker_cancel = NULL;
/* A bailout escaped the per-task/bootloader guards (e.g. during the
* scheduler drain). Re-raising zend_bailout() inside the worker fiber
* crashes it, so reject any still-pending tasks and exit cleanly. Done
* before DELREF so the pool is still alive for the drain. */
zend_object *bex = thread_pool_bailout_exception();
thread_pool_close(pool);
thread_pool_drain_tasks(pool, true, bex);
OBJ_RELEASE(bex);
/* Bailout longjmped past `done:` (which disposes slot_event). Its open
* uv_async would block uv_loop_close — dispose it while reactor is up. */
if (slot_event != NULL) {
slot_event->base.dispose(&slot_event->base);
slot_event = NULL;
}
}
/* Out of the count before the token, not after: reload() sizes its next
* cohort from this number, and a worker that has already answered with a
* token would otherwise be counted into a cohort it cannot answer for
* again, leaving that rotation waiting forever. */
zend_atomic_int_dec(&pool->live_workers);
/* Rolling reload: post one exit token iff OUR cohort channel is the one the
* active rotation retired (identity check before any notify dereference —
* dying replacements and stragglers don't send). Past zend_end_try so
* bailout exits report too; capacity == cohort size, never blocks. */
async_thread_channel_t *reload_notify =
(async_thread_channel_t *) zend_atomic_ptr_load_ex(&pool->reload_notify);
if (UNEXPECTED(reload_notify != NULL) &&
zend_atomic_ptr_load_ex(&pool->reload_old) == (void *) channel) {
zval token;
ZVAL_TRUE(&token);
if (UNEXPECTED(false == reload_notify->channel.send(&reload_notify->channel, &token))) {
zend_clear_exception();
}
}
/* Release worker's ref on pool */
ZEND_THREAD_POOL_DELREF(&pool->base);
pefree(wc, 1);
}
///////////////////////////////////////////////////////////
/// Coroutine-mode task: spawn + extended_dispose
///////////////////////////////////////////////////////////
/* Per-worker cancellation. One trigger serves every task of this worker: a
* consumer that gives up raises the flag on its own shared state and fires this
* trigger, and the callback below looks for whoever is flagged. Per-task
* triggers would double the loop handles a task costs, and the pool exists to
* be cheap per task.
*
* `inflight` maps a shared state to the coroutine running for it. An entry
* appears before the trigger is bound and disappears in pool_task_dispose, so
* "bound to this worker" and "listed here" mean the same thing — which is what
* makes the sweep on the way out complete. A task between its entry and its
* coroutine holds POOL_TASK_PREPARING; the callback passes it by, and the
* spawn re-reads the flag once the coroutine is listed.
*
* No lock: every touch — spawn, dispose, this callback, the sweep — runs on
* the worker's own thread. */
#define POOL_TASK_PREPARING ((void *) (uintptr_t) 1)
struct _pool_worker_cancel_s {
zend_async_trigger_event_t *event;
zend_async_event_callback_t callback;
HashTable inflight;
};
typedef struct {
async_thread_pool_t *pool;
/* The task's own scope, borrowed: the coroutine owns it and dispose runs
* while the coroutine is still in it. */
zend_async_scope_t *task_scope;
zend_future_shared_state_t *state;
/* Slot accounting (both NULL when concurrency=0). Pointers point into
* the worker handler's stack — safe because dispose runs in the same
* scheduler as the worker. */
int32_t *active_count;
zend_async_trigger_event_t *slot_event;
pool_worker_cancel_t *cancel;
} pool_task_ctx_t;
/* Runs in the worker's loop while the worker itself sleeps in receive(). Cancels
* every task whose consumer has asked for it. Exceptions are swallowed on
* purpose: the reactor stops on a pending exception, and one task that refused
* to be cancelled must not take the worker's other tasks with it. */
static void pool_worker_cancel_notify(zend_async_event_t *event,
zend_async_event_callback_t *callback, void *result, zend_object *exception)
{
pool_worker_cancel_t *cancel =
(pool_worker_cancel_t *) ((char *) callback - offsetof(pool_worker_cancel_t, callback));
zend_ulong key;
void *entry;
ZEND_HASH_FOREACH_NUM_KEY_PTR(&cancel->inflight, key, entry) {
if (entry == POOL_TASK_PREPARING) {
continue;
}
zend_future_shared_state_t *state = (zend_future_shared_state_t *) (uintptr_t) key;
if (async_future_shared_state_is_cancel_requested(state)) {
ZEND_ASYNC_CANCEL((zend_coroutine_t *) entry, NULL, false);
if (UNEXPECTED(EG(exception))) {
zend_clear_exception();
}
}
} ZEND_HASH_FOREACH_END();
}
/* The callback is embedded in the worker's cancel object and freed with it, so
* there is nothing to release when the trigger drops its callback list. Still
* required: the list calls dispose unconditionally. */
static void pool_worker_cancel_callback_dispose(zend_async_event_callback_t *callback, zend_async_event_t *event)
{
}
/* Called once per worker, lazily. NULL means this worker runs without in-flight
* cancellation: the reactor refused a handle, which is a sick worker, and the
* caller reports that on the task instead of running it uncancellable. */
static pool_worker_cancel_t *pool_worker_cancel_create(void)
{
zend_async_trigger_event_t *trigger = ZEND_ASYNC_NEW_TRIGGER_EVENT();
if (UNEXPECTED(trigger == NULL)) {
return NULL;
}
pool_worker_cancel_t *cancel = emalloc(sizeof(pool_worker_cancel_t));
cancel->event = trigger;
cancel->callback.ref_count = 1;
cancel->callback.callback = pool_worker_cancel_notify;
cancel->callback.dispose = pool_worker_cancel_callback_dispose;
zend_hash_init(&cancel->inflight, 8, NULL, NULL, 0);
trigger->base.add_callback(&trigger->base, &cancel->callback);
return cancel;
}
/* Returns false when cancellation was already requested: nothing is listed and
* nothing is bound, and the caller must not let the task run. */
static bool pool_worker_cancel_track(
pool_worker_cancel_t *cancel, zend_future_shared_state_t *state, void *entry)
{
zend_hash_index_update_ptr(&cancel->inflight, (zend_ulong)(uintptr_t) state, entry);
if (UNEXPECTED(false == async_future_shared_state_bind_cancel(state, cancel->event))) {
zend_hash_index_del(&cancel->inflight, (zend_ulong)(uintptr_t) state);
return false;
}
return true;
}
static void pool_worker_cancel_forget(
pool_worker_cancel_t *cancel, zend_future_shared_state_t *state)
{
zend_hash_index_del(&cancel->inflight, (zend_ulong)(uintptr_t) state);
async_future_shared_state_unbind_cancel(state);