Here is what I've come up with. It doesn't actually signal to the goroutines to stop, it just ignores their value in case of an error. The normal (no panic) route uses a WaitGroup, so it's performant.
I'm not entirely happy about the second recover() to ignore a send on closed channel, but it does the job. If anybody has any advice, or sees any concurrency problem, please let me know.
// launch N concurrent goroutines and wait for all to finish; if one of them
// panics, stop waiting and ignore any more errors
fail := make(chan interface{})
wg := new(sync.WaitGroup)
wg.Add(N)
for n := 0; n < N; n++ {
go func(n int) {
defer func() {
// if this goroutine panicked, send the panic value on fail channel
defer func() {
// ignore any sends on closed channel (panics other than the first)
// and mark as done
recover()
wg.Done()
}()
if rec := recover(); rec != nil {
// send failure on unbuffered channel before calling Done, to make
// sure main goroutine selects it
fail <- rec
}
}()
// (worker code here)
}(n)
}
// convert Wait into a channel operation, to select on it; using close
// instead of send, as main goroutine could already be gone
done := make(chan bool)
go func() {
wg.Wait()
close(done)
}()
// wait for either all goroutines to finish, or one to send a failure
select {
case <-done:
// all have exited cleanly
case rec := <-fail:
// one has failed; discard subsequent failures and raise (or return) error
close(fail)
panic(rec)
}