Skip to content

Commit 07ede7a

Browse files
Bryan C. Millsgopherbot
Bryan C. Mills
authored andcommitted
syscall: serialize locks on ForkLock on platforms where forkExecPipe is not atomic
In CL 421441, we changed syscall to allow concurrent calls to forkExec. On platforms that support the pipe2 syscall that is the right behavior, because pipe2 atomically opens the pipe with CLOEXEC already set. However, on platforms that do not support pipe2 (currently aix and darwin), syscall.forkExecPipe is not atomic, and the pipes do not initially have CLOEXEC set. If two calls to forkExec proceed concurrently, a pipe intended for one child process can be accidentally inherited by the other. If the process is long-lived, the pipe can be held open unexpectedly and prevent the parent process from reaching EOF reading the child's status from the pipe. Fixes #61080. Updates #23558. Updates #54162. Change-Id: I83edcc80674ff267a39d06260c5697c654ff5a4b Reviewed-on: https://go-review.googlesource.com/c/go/+/507355 TryBot-Result: Gopher Robot <[email protected]> Reviewed-by: Ian Lance Taylor <[email protected]> Run-TryBot: Bryan Mills <[email protected]> Auto-Submit: Bryan Mills <[email protected]>
1 parent 7dc62f3 commit 07ede7a

File tree

5 files changed

+170
-90
lines changed

5 files changed

+170
-90
lines changed

src/os/exec/exec_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1708,3 +1708,75 @@ func TestCancelErrors(t *testing.T) {
17081708
}
17091709
})
17101710
}
1711+
1712+
// TestConcurrentExec is a regression test for https://go.dev/issue/61080.
1713+
//
1714+
// Forking multiple child processes concurrently would sometimes hang on darwin.
1715+
// (This test hung on a gomote with -count=100 after only a few iterations.)
1716+
func TestConcurrentExec(t *testing.T) {
1717+
ctx, cancel := context.WithCancel(context.Background())
1718+
1719+
// This test will spawn nHangs subprocesses that hang reading from stdin,
1720+
// and nExits subprocesses that exit immediately.
1721+
//
1722+
// When issue #61080 was present, a long-lived "hang" subprocess would
1723+
// occasionally inherit the fork/exec status pipe from an "exit" subprocess,
1724+
// causing the parent process (which expects to see an EOF on that pipe almost
1725+
// immediately) to unexpectedly block on reading from the pipe.
1726+
var (
1727+
nHangs = runtime.GOMAXPROCS(0)
1728+
nExits = runtime.GOMAXPROCS(0)
1729+
hangs, exits sync.WaitGroup
1730+
)
1731+
hangs.Add(nHangs)
1732+
exits.Add(nExits)
1733+
1734+
// ready is done when the goroutines have done as much work as possible to
1735+
// prepare to create subprocesses. It isn't strictly necessary for the test,
1736+
// but helps to increase the repro rate by making it more likely that calls to
1737+
// syscall.StartProcess for the "hang" and "exit" goroutines overlap.
1738+
var ready sync.WaitGroup
1739+
ready.Add(nHangs + nExits)
1740+
1741+
for i := 0; i < nHangs; i++ {
1742+
go func() {
1743+
defer hangs.Done()
1744+
1745+
cmd := helperCommandContext(t, ctx, "pipetest")
1746+
stdin, err := cmd.StdinPipe()
1747+
if err != nil {
1748+
ready.Done()
1749+
t.Error(err)
1750+
return
1751+
}
1752+
cmd.Cancel = stdin.Close
1753+
ready.Done()
1754+
1755+
ready.Wait()
1756+
if err := cmd.Start(); err != nil {
1757+
t.Error(err)
1758+
return
1759+
}
1760+
1761+
cmd.Wait()
1762+
}()
1763+
}
1764+
1765+
for i := 0; i < nExits; i++ {
1766+
go func() {
1767+
defer exits.Done()
1768+
1769+
cmd := helperCommandContext(t, ctx, "exit", "0")
1770+
ready.Done()
1771+
1772+
ready.Wait()
1773+
if err := cmd.Run(); err != nil {
1774+
t.Error(err)
1775+
}
1776+
}()
1777+
}
1778+
1779+
exits.Wait()
1780+
cancel()
1781+
hangs.Wait()
1782+
}

src/syscall/exec_linux.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -641,11 +641,6 @@ childerror:
641641
}
642642
}
643643

644-
// Try to open a pipe with O_CLOEXEC set on both file descriptors.
645-
func forkExecPipe(p []int) (err error) {
646-
return Pipe2(p, O_CLOEXEC)
647-
}
648-
649644
func formatIDMappings(idMap []SysProcIDMap) []byte {
650645
var data []byte
651646
for _, im := range idMap {

src/syscall/exec_unix.go

Lines changed: 0 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -241,89 +241,6 @@ func forkExec(argv0 string, argv []string, attr *ProcAttr) (pid int, err error)
241241
return pid, nil
242242
}
243243

244-
var (
245-
// Guard the forking variable.
246-
forkingLock sync.Mutex
247-
// Number of goroutines currently forking, and thus the
248-
// number of goroutines holding a conceptual write lock
249-
// on ForkLock.
250-
forking int
251-
)
252-
253-
// hasWaitingReaders reports whether any goroutine is waiting
254-
// to acquire a read lock on rw. It is defined in the sync package.
255-
func hasWaitingReaders(rw *sync.RWMutex) bool
256-
257-
// acquireForkLock acquires a write lock on ForkLock.
258-
// ForkLock is exported and we've promised that during a fork
259-
// we will call ForkLock.Lock, so that no other threads create
260-
// new fds that are not yet close-on-exec before we fork.
261-
// But that forces all fork calls to be serialized, which is bad.
262-
// But we haven't promised that serialization, and it is essentially
263-
// undetectable by other users of ForkLock, which is good.
264-
// Avoid the serialization by ensuring that ForkLock is locked
265-
// at the first fork and unlocked when there are no more forks.
266-
func acquireForkLock() {
267-
forkingLock.Lock()
268-
defer forkingLock.Unlock()
269-
270-
if forking == 0 {
271-
// There is no current write lock on ForkLock.
272-
ForkLock.Lock()
273-
forking++
274-
return
275-
}
276-
277-
// ForkLock is currently locked for writing.
278-
279-
if hasWaitingReaders(&ForkLock) {
280-
// ForkLock is locked for writing, and at least one
281-
// goroutine is waiting to read from it.
282-
// To avoid lock starvation, allow readers to proceed.
283-
// The simple way to do this is for us to acquire a
284-
// read lock. That will block us until all current
285-
// conceptual write locks are released.
286-
//
287-
// Note that this case is unusual on modern systems
288-
// with O_CLOEXEC and SOCK_CLOEXEC. On those systems
289-
// the standard library should never take a read
290-
// lock on ForkLock.
291-
292-
forkingLock.Unlock()
293-
294-
ForkLock.RLock()
295-
ForkLock.RUnlock()
296-
297-
forkingLock.Lock()
298-
299-
// Readers got a chance, so now take the write lock.
300-
301-
if forking == 0 {
302-
ForkLock.Lock()
303-
}
304-
}
305-
306-
forking++
307-
}
308-
309-
// releaseForkLock releases the conceptual write lock on ForkLock
310-
// acquired by acquireForkLock.
311-
func releaseForkLock() {
312-
forkingLock.Lock()
313-
defer forkingLock.Unlock()
314-
315-
if forking <= 0 {
316-
panic("syscall.releaseForkLock: negative count")
317-
}
318-
319-
forking--
320-
321-
if forking == 0 {
322-
// No more conceptual write locks.
323-
ForkLock.Unlock()
324-
}
325-
}
326-
327244
// Combination of fork and exec, careful to be thread safe.
328245
func ForkExec(argv0 string, argv []string, attr *ProcAttr) (pid int, err error) {
329246
return forkExec(argv0, argv, attr)

src/syscall/forkpipe.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66

77
package syscall
88

9-
// Try to open a pipe with O_CLOEXEC set on both file descriptors.
9+
// forkExecPipe opens a pipe and non-atomically sets O_CLOEXEC on both file
10+
// descriptors.
1011
func forkExecPipe(p []int) error {
1112
err := Pipe(p)
1213
if err != nil {
@@ -19,3 +20,11 @@ func forkExecPipe(p []int) error {
1920
_, err = fcntl(p[1], F_SETFD, FD_CLOEXEC)
2021
return err
2122
}
23+
24+
func acquireForkLock() {
25+
ForkLock.Lock()
26+
}
27+
28+
func releaseForkLock() {
29+
ForkLock.Unlock()
30+
}

src/syscall/forkpipe2.go

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,97 @@
22
// Use of this source code is governed by a BSD-style
33
// license that can be found in the LICENSE file.
44

5-
//go:build dragonfly || freebsd || netbsd || openbsd || solaris
5+
//go:build dragonfly || freebsd || linux || netbsd || openbsd || solaris
66

77
package syscall
88

9+
import "sync"
10+
11+
// forkExecPipe atomically opens a pipe with O_CLOEXEC set on both file
12+
// descriptors.
913
func forkExecPipe(p []int) error {
1014
return Pipe2(p, O_CLOEXEC)
1115
}
16+
17+
var (
18+
// Guard the forking variable.
19+
forkingLock sync.Mutex
20+
// Number of goroutines currently forking, and thus the
21+
// number of goroutines holding a conceptual write lock
22+
// on ForkLock.
23+
forking int
24+
)
25+
26+
// hasWaitingReaders reports whether any goroutine is waiting
27+
// to acquire a read lock on rw. It is defined in the sync package.
28+
func hasWaitingReaders(rw *sync.RWMutex) bool
29+
30+
// acquireForkLock acquires a write lock on ForkLock.
31+
// ForkLock is exported and we've promised that during a fork
32+
// we will call ForkLock.Lock, so that no other threads create
33+
// new fds that are not yet close-on-exec before we fork.
34+
// But that forces all fork calls to be serialized, which is bad.
35+
// But we haven't promised that serialization, and it is essentially
36+
// undetectable by other users of ForkLock, which is good.
37+
// Avoid the serialization by ensuring that ForkLock is locked
38+
// at the first fork and unlocked when there are no more forks.
39+
func acquireForkLock() {
40+
forkingLock.Lock()
41+
defer forkingLock.Unlock()
42+
43+
if forking == 0 {
44+
// There is no current write lock on ForkLock.
45+
ForkLock.Lock()
46+
forking++
47+
return
48+
}
49+
50+
// ForkLock is currently locked for writing.
51+
52+
if hasWaitingReaders(&ForkLock) {
53+
// ForkLock is locked for writing, and at least one
54+
// goroutine is waiting to read from it.
55+
// To avoid lock starvation, allow readers to proceed.
56+
// The simple way to do this is for us to acquire a
57+
// read lock. That will block us until all current
58+
// conceptual write locks are released.
59+
//
60+
// Note that this case is unusual on modern systems
61+
// with O_CLOEXEC and SOCK_CLOEXEC. On those systems
62+
// the standard library should never take a read
63+
// lock on ForkLock.
64+
65+
forkingLock.Unlock()
66+
67+
ForkLock.RLock()
68+
ForkLock.RUnlock()
69+
70+
forkingLock.Lock()
71+
72+
// Readers got a chance, so now take the write lock.
73+
74+
if forking == 0 {
75+
ForkLock.Lock()
76+
}
77+
}
78+
79+
forking++
80+
}
81+
82+
// releaseForkLock releases the conceptual write lock on ForkLock
83+
// acquired by acquireForkLock.
84+
func releaseForkLock() {
85+
forkingLock.Lock()
86+
defer forkingLock.Unlock()
87+
88+
if forking <= 0 {
89+
panic("syscall.releaseForkLock: negative count")
90+
}
91+
92+
forking--
93+
94+
if forking == 0 {
95+
// No more conceptual write locks.
96+
ForkLock.Unlock()
97+
}
98+
}

0 commit comments

Comments
 (0)