gocronmutex

Need help in preventing multiple crons/scheduler running in golang


I'm working on a Go microservice where I am using robfig/cron package to schedule jobs, initially thought of using time.Ticker() but package made it easier for me. My question is how to ensure cron job is registered only once until the running cron completes and prevent multiple goroutines from running simultaneously.

This is different from singleton approach, the block of code inside RunProcess() must not run until the previous RunProcess function completes.

I am new to golang and its convention. :(

Here is a simplified version of my current implementation:

package main

import (
    "fmt"
    "sync"
    "time"

    "github.com/robfig/cron/v3"
)

var isProcessRunning = false
var mu sync.Mutex

func RunProcess() {
    mu.Lock()
    defer mu.Unlock()

    if isProcessRunning {
        fmt.Println("Already Running...")
        return
    }

    isProcessRunning = true
    fmt.Println("Running...")

    // Simulate work
    time.Sleep(15 * time.Second)
    isProcessRunning = false
}

func InitCron() {
    // Create a new cron scheduler
    c := cron.New(cron.WithSeconds())

    // Add the task to run every 10 seconds
    _, err := c.AddFunc("*/10 * * * * *", RunProcess)
    if err != nil {
        fmt.Println("Error adding cron job:", err)
        return
    }

    // Start the cron scheduler
    c.Start()

    // Block indefinitely to keep the cron jobs running
    select {}
}

func main() {
    InitCron()
}

However, I noticed that when InitCron is called multiple times, it can potentially create multiple cron jobs, leading to concurrency issues and unexpected behavior for an lightweight microservice.

Any advice or examples on how to manage this properly would be greatly appreciated.

Running cron in golang and tryin to prevent the cron until it first cron is finished


Solution

  • Your code is already preventing "multiple goroutines from running simultaneously" but probably not in the way you expect. There are two ways you could handle this:

    1. Concurrent calls block until the earlier call completes (a queue may form).
    2. Concurrent calls exit without performing the task (i.e. time.Sleep).

    Your code currently takes the first approach; but the fmt.Println("Already Running...") indicates that you expect it to take the second.

    So why is "Already Running..." never output? This is due to the way you are using the Mutex. When you call Lock:

    Lock locks m. If the lock is already in use, the calling goroutine blocks until the mutex is available.

    This means that when RunProcess is called concurrently, execution will block at mu.Lock() until mu.Unlock() is called (in a seperate goroutine). You defer the mu.Unlock() call which means the Mutex is unlocked "the moment the surrounding function returns". This may be easier to see if taken step by step:

    1. First RunProcess() begins, calls mu.Lock() and proceeds to time.Sleep(15 * time.Second).
    2. Second RunProcess() begins, blocks at mu.Lock().
    3. First RunProcess() completes, this means the deferred mu.Unlock() runs.
    4. Second RunProcess() acquires Mutex and proceeds to time.Sleep(15 * time.Second).

    I believe that whay you intend to happen is for the second RunProcess() to output "Already Running..." and terminate; if that is the case your code would need to be more like playground:

    func RunProcess() {
        mu.Lock() // Mutex protects isProcessRunning variable    
        if isProcessRunning {
            fmt.Println("Already Running...")
            mu.Unlock()
            return
        }
        isProcessRunning = true
        mu.Unlock() // isProcessRunning has been checked/set so release the Mutex (allowing other checks to proceed)
    
        fmt.Println("Running...")
    
        // Simulate work
        time.Sleep(2 * time.Second)
    
        fmt.Println("Done")
    
        mu.Lock() // Need to access isProcessRunning so acquire Mutex
        isProcessRunning = false
        mu.Unlock()
    }
    

    Hopefully this answers the first part of your question. I believe the second part ref "InitCron is called multiple times" is a duplicate (i.e. use sync.Once). However it could be argued that documenting that InitCron must only be called once may be sufficient.

    Moving on to how your solution could be improved; I'm going to assume that you have chosen robfig/cron for valid reasons (I use it for scheduling reports etc). Preventing concurrent execution of tasks is a common requirement so robfig/cron provides SkipIfStillRunning; there is an example in the docs.