I have a scenario where I need to acquire lock on a resource inside a goroutine and release the lock (only once) after a timeout or when the goroutine is done with it's job.
I came up with a way to achieve this, but I'm not sure if it's best way.
package main
import (
"fmt"
"runtime/debug"
"strings"
"time"
)
func testTimeoutRoutine() {
// Handle panic in goroutine so the caller is not impacted
defer func() {
if r := recover(); r != nil {
defer debug.PrintStack()
if err, ok := r.(error); ok {
if strings.Contains(err.Error(), "send on closed channel") {
fmt.Println("goroutine job done after timeout")
return
}
}
fmt.Printf("Panic in goroutine => %v", r)
}
}()
jobDone := make(chan bool)
go func() {
defer close(jobDone)
timer := time.After(2 * time.Second)
for {
select {
case <-timer:
fmt.Println("goroutine timed out before it's job is done")
// Release lock on the resource
return
case <-jobDone:
fmt.Println("goroutine job is done")
// Release lock on the resource
return
}
}
}()
// Acquire lock on a resource
// Do some job
time.Sleep(5 * time.Second)
jobDone <- true
}
func main() {
go testTimeoutRoutine()
// Let's wait for the goroutine to do it's job or timeout
time.Sleep(10 * time.Second)
fmt.Println("\n\nmain done")
}
Is there a better way of handling this?
Code here: https://go.dev/play/p/Jigu8MK2aEA
-
1\$\begingroup\$ Please edit your question so that the title describes the purpose of the code, rather than its mechanism. We really need to understand the motivational context to give good reviews. It's best to describe what value this code provides to its user. \$\endgroup\$Toby Speight– Toby Speight2024年08月23日 08:03:56 +00:00Commented Aug 23, 2024 at 8:03
2 Answers 2
Use a context with timeout:
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// Lock the mutex
mu.Lock()
defer mu.Unlock()
// Do work
// every now and then, check if timed out
if ctx.Err()!=nil {
return
}
// Do more work
// When goroutine ends, mutex will be unlocked.
}()
-
\$\begingroup\$ // every now and then, check if timed out @Burak How do yo propose I do this? It has to be done via another go routine right? Which is what I sort of did. \$\endgroup\$Ram Dittakavi– Ram Dittakavi2024年08月23日 19:14:26 +00:00Commented Aug 23, 2024 at 19:14
-
\$\begingroup\$ You can check if something timed out using another goroutine. That's what context timeout does, so you don't need another goroutine. The problem with your code is that the other goroutine detects the timeout, but the process that timed out continues running to completion. You have to check often to detect timeout happened. Unlocking a mutex like you did will cause race, because the critical section is still running even though it timed out and mutex is released. \$\endgroup\$Burak Serdar– Burak Serdar2024年08月23日 19:33:38 +00:00Commented Aug 23, 2024 at 19:33
-
\$\begingroup\$ I understand the race condition part which I totally missed, thanks a lot for that. But, I still don't get how I should periodically check the context if it timed out. Are you saying I should keep inserting if conditions randomly at different places to detect timeout? \$\endgroup\$Ram Dittakavi– Ram Dittakavi2024年08月24日 02:13:07 +00:00Commented Aug 24, 2024 at 2:13
-
\$\begingroup\$ Yes. If the long running task is a for-loop, it is usually done once every iteration. If it is sequential, you can do it at certain milestones. \$\endgroup\$Burak Serdar– Burak Serdar2024年08月24日 03:11:44 +00:00Commented Aug 24, 2024 at 3:11
-
\$\begingroup\$ I still feel this approach is inefficient and incomplete. The reason to use timeout is to detect if there is anything in the code that got stuck and there by essentially kill the goroutine. To achieve this, we have to use a separate goroutine where the release is locked and something to kill the goroutine (which I feel is through raising panic()). But, your approach doesn't solve the problem as the code that might get stuck could be between any two if conditions that check for the timeout. \$\endgroup\$Ram Dittakavi– Ram Dittakavi2024年08月24日 14:43:20 +00:00Commented Aug 24, 2024 at 14:43
A better way to do this would be with the builtin context library. In general, if normal behavior of your program requires a panic to work, something's wrong.
A context.Context
is a bit complicated, but the feature we care about here is that they have a channel, .Done()
, which is closed when the Context is cancelled. Closed channels immediately send zero values to all receivers, satisfying the ctx.Done()
branch of the select
statement.
By instantiating a Context and then creating a child Context via WithTimeout
, we create one which will automatically be cancelled after the specified time is elapsed.
package main
import (
"context"
"fmt"
"time"
)
func testTimeoutRoutine(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
jobDone := make(chan bool)
go func() {
defer close(jobDone)
defer cancel()
for {
select {
case <-ctx.Done():
fmt.Println("goroutine timed out before it's job is done")
// Release lock on the resource
return
case <-jobDone:
fmt.Println("goroutine job is done")
// Release lock on the resource
return
}
}
}()
// Acquire lock on a resource
// Do some job
time.Sleep(5 * time.Second)
select {
case <-ctx.Done():
// Take no action since context was cancelled.
default:
jobDone <- true
}
}
func main() {
ctx := context.Background()
go testTimeoutRoutine(ctx)
// Let's wait for the goroutine to do it's job or timeout
time.Sleep(10 * time.Second)
fmt.Println("\n\nmain done")
}
-
\$\begingroup\$ What exactly is the goroutine doing in this scenario? It does not timeout the job, it only detects the timeout only after the job is done. \$\endgroup\$Burak Serdar– Burak Serdar2024年08月23日 03:26:43 +00:00Commented Aug 23, 2024 at 3:26
-
\$\begingroup\$ I tried to keep the goroutine's purpose as close to the original post's as possible: the goroutine is responsible for releasing the resource's lock when either (A) the request has timed out or (B) the job is completed. I did, however, change the timeout to be set outside the goroutine so that the goroutine's caller can be notified of completion (e.g. so the job knows to stop and not write to the closed channel). Ideally if we has the "run job" function available - it would have access to this context.WithDeadline. \$\endgroup\$Will Beason– Will Beason2024年08月23日 03:32:38 +00:00Commented Aug 23, 2024 at 3:32
-
2\$\begingroup\$ Releasing a lock like this makes code race-prone, as the job would be running with mutex unlocked. Also, if the job completes without a timeout, the unlock happens an unknown time later. It is not a good idea to unlock a resource from a separate goroutine. \$\endgroup\$Burak Serdar– Burak Serdar2024年08月23日 03:35:00 +00:00Commented Aug 23, 2024 at 3:35