Thread Safe Stack Locks

Thread-safe Stack using Locks

Welcome to the first lesson of our course on lock-based concurrent data structures. Here, we will delve into implementing a thread-safe stack using locks in Go. You may remember that we have already touched on the topic of synchronization mechanisms like sync.Mutex in previous discussions. This lesson provides a hands-on approach to exploring how locks help us ensure thread safety and consistency when accessing shared resources. Let's embark on this journey, where you will transform typical data structures into robust, concurrent ones.

What You'll Learn

In this lesson, you will learn how to create a stack that multiple goroutines can access without encountering race conditions. A thread-safe stack ensures that operations like pushing and popping elements can be performed safely across multiple goroutines. You will employ sync.Mutex for synchronization, which is crucial for locking access to the stack and managing concurrent tasks effectively.

Thread-safe Stack using Locks

type ThreadsafeStack struct {
    data []int
    mu   sync.Mutex
}

func (s *ThreadsafeStack) Push(value int) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.data = append(s.data, value)
}

func (s *ThreadsafeStack) Pop() (int, error) {
    s.mu.Lock()
    defer s.mu.Unlock()
    if len(s.data) == 0 {
        return 0, fmt.Errorf("stack is empty")
    }
    value := s.data[len(s.data)-1]
    s.data = s.data[:len(s.data)-1]
    return value, nil
}

func (s *ThreadsafeStack) Empty() bool {
    s.mu.Lock()
    defer s.mu.Unlock()
    return len(s.data) == 0
}

Let's break down the methods:

  • Push(value int): This method pushes a new element onto the stack. It uses sync.Mutex to lock the mutex mu and ensure that only one goroutine can access the stack at a time. The defer s.mu.Unlock() statement ensures that the mutex is unlocked when the function returns, even if an error occurs. We use Go's built-in append function to add the value to our slice-based stack.
  • Pop() (int, error): This method pops the top element from the stack and returns it along with an error value. It also uses the mutex to lock and prevent multiple goroutines from accessing the stack simultaneously. If the stack is empty, it returns an error. The defer keyword ensures the mutex is always unlocked when the function exits. We access the last element of the slice, then shrink the slice to remove it.
  • Empty() bool: This method checks if the stack is empty. It also uses the mutex to ensure that the operation is thread-safe. It returns true if the length of the underlying slice is 0.

Sample Usage

We can use this stack in a multi-threaded environment without worrying about data corruption. Here is a sample usage with 10 goroutines pushing and 10 popping elements:

func main() {
    stack := &ThreadsafeStack{}
    var wg sync.WaitGroup

    // Launch 10 goroutines to push values
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(val int) {
            defer wg.Done()
            stack.Push(val)
            fmt.Printf("Pushed: %d\n", val)
        }(i)
    }

    // Launch 10 goroutines to pop values
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            value, err := stack.Pop()
            if err != nil {
                fmt.Printf("Error popping: %v\n", err)
                return
            }
            fmt.Printf("Popped: %d\n", value)
        }()
    }

    wg.Wait()
}

In this example, we use a sync.WaitGroup to coordinate our goroutines. We call wg.Add(1) before launching each goroutine and defer wg.Done() inside each goroutine to signal completion. The wg.Wait() call blocks until all goroutines have finished executing.

In this lesson, you will learn how to implement a thread-safe stack using locks and understand the underlying concepts that make it work.

Why It Matters

Understanding how to implement a thread-safe stack is fundamental to building applications that require concurrent processing. Whether you are dealing with real-time data processing or any multi-threaded environment, ensuring safe access to shared data structures is imperative. By implementing locks, you minimize errors like race conditions and ensure the integrity of your data even when multiple goroutines are at play.

The knowledge you gain here not only enables you to handle concurrency in stacks but also sets a strong foundation for managing other data structures. It is exciting to see how these concepts come together to enhance application performance and reliability. Are you ready to take the next step and apply these concepts in practice? Let's dive in!

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal