Skip to content

Commit df24ab2

Browse files
sync: fix ordering in Once and ManyTimes -- store count after f() completes (#27490)
* sync: fix Once ordering -- store done flag after f() completes, not before Previously do_slow/do_slow_with_param set count=1 before calling f(), so a concurrent goroutine could observe count==1 on the fast path and return from do() while f() was still executing. This broke the fundamental Once guarantee that when do() returns, f() side effects are visible. Fix: move the stdatomic.store_u64 to after f() returns, matching the ordering used by Go sync.Once. Concurrent callers that arrive while f() is running now correctly block on the mutex until f() completes. Adds a test that would fail with the old ordering: the second do_with_param call must not return until slow_init has set its value. Fixes #27456. Co-Authored-By: WOZCODE <contact@withwoz.com> * sync: fix ManyTimes ordering -- store count after f() completes, not before The same ordering bug fixed in sync.Once (see #27456): `do_slow` was incrementing `count` before calling `f()`, so a concurrent goroutine could observe `count >= times` on the atomic fast path and return from `do` while `f()` was still executing. Move `stdatomic.store_u64` to after `f()` so that `count` is only incremented once the work is complete. Adds a concurrency ordering test analogous to `test_once_with_param_ordering` in `once_with_param_test.v`. Co-Authored-By: WOZCODE <contact@withwoz.com> --------- Co-authored-by: WOZCODE <contact@withwoz.com>
1 parent a66aa5b commit df24ab2

4 files changed

Lines changed: 100 additions & 5 deletions

File tree

vlib/sync/many_times.v

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ pub fn (mut m ManyTimes) do(f fn ()) {
2929
fn (mut m ManyTimes) do_slow(f fn ()) {
3030
m.m.lock()
3131
if m.count < m.times {
32-
stdatomic.store_u64(&m.count, m.count + 1)
3332
f()
33+
stdatomic.store_u64(&m.count, m.count + 1)
3434
}
3535
m.m.unlock()
3636
}

vlib/sync/many_times_test.v

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// vtest build: !windows && (amd64 || arm64)
22
import sync
3+
import time
34

45
struct Counter {
56
pub mut:
@@ -48,3 +49,45 @@ fn test_many_times_fifth() {
4849
}
4950
assert co.i == 25
5051
}
52+
53+
// Ordering test: do must not return until f() has completed.
54+
// The bug was that count was stored *before* f() ran, so a concurrent
55+
// caller could observe count >= times and return on the fast path while
56+
// f() was still executing.
57+
58+
struct ManyTimesState {
59+
pub mut:
60+
value int
61+
ready chan bool
62+
}
63+
64+
fn run_mt_ordering(mut m sync.ManyTimes, s &ManyTimesState, c chan bool) {
65+
m.do(fn [s] () {
66+
mut ms := unsafe { &ManyTimesState(s) }
67+
ms.ready <- true
68+
time.sleep(50 * time.millisecond)
69+
ms.value = 99
70+
})
71+
c <- true
72+
}
73+
74+
fn test_many_times_ordering() {
75+
s := &ManyTimesState{
76+
ready: chan bool{cap: 1}
77+
}
78+
mut m := sync.new_many_times(1)
79+
c := chan bool{}
80+
81+
spawn run_mt_ordering(mut m, s, c)
82+
83+
// Wait until the goroutine is inside f(). With the old buggy code,
84+
// count is incremented before f() runs, so the second do() call sees
85+
// count >= times on the fast path and returns immediately — before
86+
// f() has finished. With the fix, count is still 0 here, so the
87+
// second do() blocks on the mutex until f() completes.
88+
_ := <-s.ready
89+
m.do(fn () {})
90+
91+
assert s.value == 99, 'do must not return before f() completes'
92+
_ := <-c
93+
}

vlib/sync/once.v

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ pub fn new_once() &Once {
1616
return once
1717
}
1818

19-
// do executes the function `f()` only once.
19+
// do executes the function `f()` only once, regardless of how many goroutines
20+
// call it concurrently. When do returns, f() has completed.
21+
// Note: if f() calls do on the same Once, it will deadlock.
2022
pub fn (mut o Once) do(f fn ()) {
2123
if stdatomic.load_u64(&o.count) < 1 {
2224
o.do_slow(f)
@@ -26,8 +28,8 @@ pub fn (mut o Once) do(f fn ()) {
2628
fn (mut o Once) do_slow(f fn ()) {
2729
o.m.lock()
2830
if o.count < 1 {
29-
stdatomic.store_u64(&o.count, 1)
3031
f()
32+
stdatomic.store_u64(&o.count, 1)
3133
}
3234
o.m.unlock()
3335
}
@@ -49,7 +51,10 @@ fn (mut o Once) do_slow(f fn ()) {
4951
// }, o)
5052
// ```
5153

52-
// do_with_param executes the function `f()` with parameter `param` only once.
54+
// do_with_param executes the function `f(param)` only once, regardless of how
55+
// many goroutines call it concurrently. When do_with_param returns, f() has
56+
// completed.
57+
// Note: if f() calls do_with_param on the same Once, it will deadlock.
5358
pub fn (mut o Once) do_with_param(f fn (voidptr), param voidptr) {
5459
if stdatomic.load_u64(&o.count) < 1 {
5560
o.do_slow_with_param(f, param)
@@ -59,8 +64,8 @@ pub fn (mut o Once) do_with_param(f fn (voidptr), param voidptr) {
5964
fn (mut o Once) do_slow_with_param(f fn (p voidptr), param voidptr) {
6065
o.m.lock()
6166
if o.count < 1 {
62-
stdatomic.store_u64(&o.count, 1)
6367
f(param)
68+
stdatomic.store_u64(&o.count, 1)
6469
}
6570
o.m.unlock()
6671
}

vlib/sync/once_with_param_test.v

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import sync
2+
import time
23

34
// Note: this is the same test as `vlib/sync/once_test.v`, but
45
// it uses an explicit passing of the voidptr parameter in
@@ -36,3 +37,49 @@ fn test_once() {
3637
}
3738
assert o.i == 5
3839
}
40+
41+
// Ordering test: do_with_param must not return until f() has completed.
42+
// The bug was that count was set to 1 *before* f() ran, so a concurrent
43+
// caller could observe count==1 and return on the fast path while f() was
44+
// still executing (or hadn't started).
45+
46+
struct OrderingState {
47+
pub mut:
48+
value int
49+
ready chan bool
50+
}
51+
52+
fn slow_init(p voidptr) {
53+
mut s := unsafe { &OrderingState(p) }
54+
// Signal that we are inside f() — count has been stored (buggy) or not (fixed).
55+
s.ready <- true
56+
// Sleep long enough for the concurrent do_with_param call to observe the state.
57+
time.sleep(50 * time.millisecond)
58+
s.value = 99
59+
}
60+
61+
fn run_ordering(mut once sync.Once, s &OrderingState, c chan bool) {
62+
once.do_with_param(slow_init, s)
63+
c <- true
64+
}
65+
66+
fn test_once_with_param_ordering() {
67+
mut s := &OrderingState{
68+
ready: chan bool{cap: 1}
69+
}
70+
mut once := sync.new_once()
71+
c := chan bool{}
72+
73+
spawn run_ordering(mut once, s, c)
74+
75+
// Wait until the goroutine is inside f() before letting the second
76+
// caller proceed. With the old buggy code count==1 is already visible
77+
// here, so the second do_with_param returns immediately — before
78+
// s.value is set. With the fix, count is still 0, so the second
79+
// caller blocks on the mutex and waits for f() to complete.
80+
_ := <-s.ready
81+
once.do_with_param(fn (p voidptr) {}, unsafe { nil })
82+
83+
assert s.value == 99, 'do_with_param must not return before f() completes'
84+
_ := <-c
85+
}

0 commit comments

Comments
 (0)