Hone logo
Hone
Problems

Implementing a Lock-Free Queue in Go

Concurrency is a fundamental aspect of modern software development, and efficient communication between goroutines is crucial. Traditional synchronization primitives like mutexes can introduce performance bottlenecks due to blocking and potential deadlocks. This challenge focuses on building a lock-free queue in Go, demonstrating how to achieve thread-safe data structures without relying on locks.

Problem Description

Your task is to implement a LockFreeQueue data structure in Go. This queue should allow multiple goroutines to concurrently Enqueue and Dequeue elements without using any mutexes or other locking mechanisms. The implementation should leverage atomic operations provided by Go's sync/atomic package to ensure thread safety and avoid race conditions.

Key Requirements:

  • Thread-Safe Enqueue: Multiple goroutines should be able to add elements to the queue concurrently and safely.
  • Thread-Safe Dequeue: Multiple goroutines should be able to remove elements from the queue concurrently and safely.
  • No Locks: The implementation must not use sync.Mutex, sync.RWMutex, or any other blocking synchronization primitives.
  • FIFO Order: The queue must maintain a First-In, First-Out (FIFO) order for enqueued and dequeued elements.
  • nil on Empty: When Dequeue is called on an empty queue, it should return nil (or a zero value for the element type) and a boolean indicating failure (e.g., false).

Expected Behavior:

When multiple goroutines interact with the queue, the operations should appear atomic and consistent, as if they were executed sequentially. Elements enqueued should be dequeued in the order they were added.

Edge Cases:

  • Empty Queue Dequeue: Handling dequeue operations on an empty queue gracefully.
  • Concurrent Enqueue/Dequeue: Ensuring that the queue remains consistent when both enqueue and dequeue operations happen simultaneously from different goroutines.
  • Large Number of Operations: The queue should be performant under high contention.

Examples

Example 1: Basic Enqueue and Dequeue

// Assume `queue` is a LockFreeQueue of integers

// Goroutine 1:
queue.Enqueue(1)

// Goroutine 2:
queue.Enqueue(2)

// Goroutine 3:
val1, ok1 := queue.Dequeue() // val1 should be 1, ok1 should be true
val2, ok2 := queue.Dequeue() // val2 should be 2, ok2 should be true

Explanation: Elements are added and then removed in the order they were enqueued.

Example 2: Concurrent Operations

// Assume `queue` is a LockFreeQueue of strings

var wg sync.WaitGroup
numGoroutines := 1000
elementsPerGoroutine := 10

// Enqueuing goroutines
for i := 0; i < numGoroutines; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        for j := 0; j < elementsPerGoroutine; j++ {
            queue.Enqueue(fmt.Sprintf("msg-%d-%d", id, j))
        }
    }(i)
}
wg.Wait() // Ensure all enqueues are done

// Dequeuing goroutines
results := make([]string, 0, numGoroutines*elementsPerGoroutine)
var mu sync.Mutex // Used only for collecting results, not for queue operations
for i := 0; i < numGoroutines; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        for {
            val, ok := queue.Dequeue()
            if !ok {
                // Queue might be temporarily empty or truly empty.
                // In a real scenario, might need a way to signal completion.
                // For this example, we'll assume we eventually drain it.
                time.Sleep(10 * time.Millisecond) // Short pause to avoid busy-waiting too hard
                continue
            }
            mu.Lock()
            results = append(results, val.(string))
            mu.Unlock()
            if len(results) == numGoroutines*elementsPerGoroutine {
                return // All expected elements dequeued
            }
        }
    }()
}
wg.Wait()

// Verify that all elements were dequeued and are unique.
// The exact order might vary due to concurrency, but all should be present.
fmt.Printf("Total elements dequeued: %d\n", len(results))
// Additional checks can be added to ensure all expected unique strings are present.

Explanation: This example simulates a scenario with many concurrent producers and consumers. The LockFreeQueue must handle these operations without data loss or corruption, and all elements should eventually be dequeued. The results slice would contain all numGoroutines * elementsPerGoroutine strings, though not necessarily in a predictable order across runs.

Example 3: Dequeue from Empty Queue

// Assume `queue` is an empty LockFreeQueue of integers

val, ok := queue.Dequeue()

// Expected: val should be 0 (or the zero value for the type), ok should be false

Explanation: When the queue is empty, Dequeue should signal that no element was available.

Constraints

  • The queue should be generic, capable of storing any type of data. You can use interface{} or Go's generic type parameters.
  • The implementation must be thread-safe for at least 1000 concurrent goroutines performing a mix of enqueue and dequeue operations.
  • Performance should be considered; the goal is to outperform a mutex-based queue under high contention.
  • The maximum number of elements in the queue is not explicitly bounded, but memory limitations apply.

Notes

This challenge is a classic problem in concurrent programming. You will likely need to use atomic operations like atomic.LoadPointer, atomic.StorePointer, atomic.CompareAndSwapPointer, and potentially atomic.AddUint64 for sequence numbers or other state.

Consider implementing the queue using a linked list structure. The core challenge lies in atomically updating the head and tail pointers of the list during enqueue and dequeue operations to prevent race conditions. Be mindful of the ABA problem, although for many simple linked-list based queues, it can be mitigated with careful design or by using techniques that don't rely solely on pointer equality. For this challenge, you can assume a basic lock-free approach that avoids ABA for simplicity, or use techniques to address it if you are familiar.

Loading editor...
go