goSeries · 1/62026년 3월 7일18 min read
Golang Concurrency

Golang Concurrency Part 1 - Overview and Goroutine Basics

Covers the fundamental concepts of Go concurrency — Concurrency vs Parallelism, the CSP model, the GMP scheduler, and goroutine basics

FFrank
#golang#concurrency#goroutine

One of the biggest features that sets Go apart from other languages is its support for concurrency. Go provides goroutines and channels at the language level, designed so that you can write complex concurrent programming concisely and safely.

In this series, we cover Go concurrency step by step, from the basics to real-world use. In this first part, we look at the basic concepts of concurrency and Go's core unit of execution, the goroutine.

1. Concurrency vs Parallelism

cover

Concurrency and parallelism are often confused, but they are different concepts.

AspectConcurrencyParallelism
Definitiona structure for dealing with multiple tasks at onceexecuting multiple tasks at the same time
Coretask compositiontask execution
CPUpossible even on a single CPUrequires multiple CPUs
Analogyone person alternating between several tasksseveral people each doing their task simultaneously
graph LR
    subgraph "Concurrency (one CPU)"
        A1[Task A] --> B1[Task B] --> A2[Task A] --> B2[Task B]
    end
graph LR
    subgraph "Parallelism (multiple CPUs)"
        C1[CPU 1: Task A] --> C1E[ ]
        C2[CPU 2: Task B] --> C2E[ ]
    end
    style C1E fill:none,stroke:none
    style C2E fill:none,stroke:none

Go's creator Rob Pike explains it this way:

"Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once." — Rob Pike

In Go, concurrency is about the structure of a program. Separating code into independently executable units is concurrency, and actually running them simultaneously on multiple CPUs is parallelism. If you design a Go program to be concurrent, the runtime takes care of leveraging parallelism.

Open slides in a new tab →

Click the slides, then to navigate · f fullscreen · ? help

2. Why Is Go Strong at Concurrency?

2.1 The CSP Model

Go's concurrency model is based on CSP (Communicating Sequential Processes). The core of this model, proposed by Tony Hoare in 1978, is that independent processes communicate through message passing.

In Go, this is implemented with goroutines (independent units of execution) and channels (the means of message passing).

2.2 Go's Concurrency Philosophy

"Do not communicate by sharing memory; instead, share memory by communicating." — Go Proverb

In traditional multithreaded programming, access to shared memory is protected with locks (mutexes). This approach is prone to problems such as deadlocks and race conditions.

Go recommends passing data through channels. Because ownership of the data is transferred through the channel, only one goroutine accesses the data at a given moment.

graph LR
    subgraph "Traditional approach (Shared Memory + Lock)"
        TA[Thread A] --> SD[Shared Data<br/>+ Lock]
        TB[Thread B] --> SD
    end
graph LR
    subgraph "Go approach (Message Passing)"
        GA[Goroutine A<br/>owns data] -- "channel (passing)" --> GB[Goroutine B<br/>receives data]
    end

3. When Should You Use Concurrency?

3.1 Cases Where It's a Good Fit

  • I/O-heavy work: HTTP requests, DB queries, file read/write
  • Parallel processing of independent tasks: calling multiple APIs simultaneously
  • Event-based processing: handling requests in a web server
  • Pipeline processing: chaining data transformation stages

3.2 Cases Where You Shouldn't Use It (Over-Engineering)

  • When simple sequential processing is enough: simple data transformations
  • When you create excessive goroutines for CPU-bound work
  • When there's so much shared state that locks become complex: in this case, reconsider the design
  • When it becomes so complex that debugging is hard: concurrency adds complexity

4. Goroutine Basics

4.1 What Is a Goroutine?

A goroutine is a lightweight unit of execution managed by the Go runtime. Putting the go keyword in front of a function call creates a new goroutine.

// create a goroutine - using the go keyword
go func() {
    fmt.Println("goroutine ran")
}()

// named functions work too
go sayHello("World")

4.2 Goroutine vs OS Thread

AspectGoroutineOS Thread
Initial stack size~2KB (grows dynamically)~1MB (fixed)
Creation costvery cheaprelatively expensive
SchedulingGo runtime (user space)OS kernel
Concurrent counthundreds of thousands possiblethousands range
Context switchingfast (3 registers)slow (all registers)

Goroutines are multiplexed on top of OS threads. Thousands to tens of thousands of goroutines run efficiently on a small number of OS threads.

4.3 Execution Order Is Non-Deterministic

The execution order of goroutines is not guaranteed. In the code below, you should not expect the numbers to be printed in order.

func TestGoroutineNonDeterministicOrder(t *testing.T) {
    var mu sync.Mutex
    var order []int
    var wg sync.WaitGroup

    const numGoroutines = 10
    wg.Add(numGoroutines)

    for i := range numGoroutines {
        go func() {
            defer wg.Done()
            mu.Lock()
            order = append(order, i)
            mu.Unlock()
        }()
    }

    wg.Wait()
    t.Logf("execution order: %v", order)
    // example output: execution order: [1 4 2 3 5 9 8 0 6 7]
}

4.4 main goroutine and lifecycle

In a Go program, the main() function runs in the main goroutine. When the main goroutine terminates, the entire program terminates regardless of whether other goroutines have finished.

func TestMainExitKillsGoroutines(t *testing.T) {
    var completed atomic.Bool

    go func() {
        time.Sleep(100 * time.Millisecond) // time-consuming work
        completed.Store(true)
    }()

    // if you don't wait, the goroutine won't complete
    assert.False(t, completed.Load())
}

To wait until a goroutine completes, you need to use sync.WaitGroup or a channel.

func TestWaitGroupSolution(t *testing.T) {
    var completed atomic.Bool
    var wg sync.WaitGroup

    wg.Add(1)
    go func() {
        defer wg.Done()
        time.Sleep(50 * time.Millisecond)
        completed.Store(true)
    }()

    wg.Wait() // wait until the goroutine completes
    assert.True(t, completed.Load())
}

4.5 Creating Tens of Thousands of Goroutines

Goroutines are so lightweight that creating tens of thousands of them is no problem.

func TestGoroutineLightweight(t *testing.T) {
    const numGoroutines = 10000
    var counter atomic.Int64
    var wg sync.WaitGroup
    wg.Add(numGoroutines)

    for range numGoroutines {
        go func() {
            defer wg.Done()
            counter.Add(1)
        }()
    }

    wg.Wait()
    assert.Equal(t, int64(numGoroutines), counter.Load())
    // all 10000 goroutines completed
}

5. Comparison with Other Languages

To better understand the characteristics of goroutines, let's compare them with Kotlin Coroutines and Java Threads.

5.1 Overall Comparison

AspectGo GoroutineKotlin CoroutineJava Platform ThreadJava Virtual Thread (21+)
Stack size~2KB (grows dynamically)stackless (heap object)~1MB (fixed)~a few KB (dynamic)
SchedulingGo runtime (preemptive)cooperative (suspend/resume)OS kernelJVM (cooperative)
Creation costvery cheapvery cheapexpensivecheap
Concurrent counthundreds of thousandshundreds of thousandsthousandsmillions
CommunicationChannel (CSP)Flow, Channelsynchronized, Locksynchronized, Lock

5.2 Goroutine vs Kotlin Coroutine

The biggest difference is the scheduling method.

Go's goroutines use preemptive scheduling. If a goroutine holds the CPU for a long time, the Go runtime forcibly switches it (Go 1.14+). In contrast, Kotlin coroutines use cooperative scheduling, where switching happens only at suspend points.

// Kotlin - suspension points occur only in suspend functions
suspend fun fetchData() {
    delay(1000)  // yields here
    // CPU work without suspend does not yield
}
// Go - the runtime switches automatically with no special keyword
func fetchData() {
    time.Sleep(time.Second)
    // the runtime forcibly switches even CPU-bound work
}

The function coloring problem is another important difference.

  • Kotlin: a suspend function can only be called within a suspend function or a coroutine. To convert existing synchronous code to asynchronous, you may have to propagate suspend throughout the entire call chain
  • Go: all functions are the same. You can call any function from a goroutine, and there's no async/suspend distinction

On the other hand, Kotlin has some advantages too:

  • Structured Concurrency built in — when a parent coroutine is canceled, its children are automatically canceled too
  • Error propagation is systematic — consistent handling is possible with CoroutineExceptionHandler

5.3 Goroutine vs Java Thread

A traditional Java Platform Thread maps 1:1 to an OS thread and takes up a ~1MB stack. Creating thousands or more causes memory and context-switching costs to spike.

// Java Platform Thread - 1:1 mapping to an OS thread
new Thread(() -> doWork()).start(); // allocates a ~1MB stack

The Virtual Thread introduced in Java 21 is a lightweight thread conceptually similar to a goroutine.

// Java Virtual Thread - similar to a goroutine
Thread.startVirtualThread(() -> doWork());

However, Java does not have a communication mechanism like Channel built into the language, so you have to use separate tools such as BlockingQueue or CompletableFuture.

5.4 Summary of Goroutine's Core Strengths

  1. Language built-in: go + chan are provided as keywords, so no separate library is needed
  2. No function coloring problem: there's no async/await/suspend distinction — all functions are the same
  3. Preemptive scheduling: the runtime automatically switches even CPU-bound goroutines
  4. Consistent ecosystem: the entire standard library is designed around goroutines

As weaknesses, there's the lack of structured concurrency (you need to manually manage Context/WaitGroup) and the fact that a panic in a goroutine can terminate the entire program.

6. Goroutine Scheduling Concepts

6.1 The GMP Model

The Go runtime schedules goroutines with the GMP model. Rather than the OS directly managing goroutines, the Go runtime performs scheduling itself in user space. Thanks to this, context switching is possible at a much lower cost than OS threads.

graph TD
    subgraph "Go Scheduler (GMP model)"
        subgraph "G (Goroutine)"
            G1[G1]
            G2[G2]
            G3[G3]
            G4[G4]
            G5[G5]
            G6[G6]
        end
        subgraph "M (OS Thread)"
            M1[M1]
            M2[M2]
        end
        subgraph "P (logical processor)"
            P1[P1]
            P2[P2]
        end
    end

    P1 --> M1
    G1 -- "running" --> P1
    G2 -- "waiting (run queue)" --> P1
    G3 -- "waiting (run queue)" --> P1

In the diagram above, P1 is bound to M1 (an OS thread) and is running G1, while G2 and G3 are waiting in P1's local run queue. When G1 blocks on I/O waiting and the like, P1 immediately pulls G2 from the run queue and runs it.

ComponentRole
G (Goroutine)a lightweight unit of execution holding the function to run and stack info
M (Machine)OS thread. actually runs code on the CPU
P (Processor)logical processor. manages the goroutine run queue

The scheduling flow can be summarized as follows:

  1. When a new goroutine (G) is created, it is added to the current P's local run queue
  2. P pulls goroutines from the run queue one by one and runs them on the bound M (OS thread)
  3. When a running goroutine blocks on I/O waiting, channel waiting, time.Sleep, etc., P switches to the next goroutine
  4. When the local run queue is empty, it fetches a goroutine from another P's queue via work stealing

6.2 runtime.GOMAXPROCS

runtime.GOMAXPROCS(n) sets the maximum number of Ps (Processors) that can run goroutines simultaneously. The default is the number of CPU cores. That is, on a 4-core machine, up to 4 goroutines can run physically at the same time.

func TestGOMAXPROCS(t *testing.T) {
    currentProcs := runtime.GOMAXPROCS(0) // passing 0 returns the current value without changing it
    numCPU := runtime.NumCPU()

    t.Logf("number of CPUs: %d", numCPU)           // e.g. number of CPUs: 12
    t.Logf("current GOMAXPROCS: %d", currentProcs) // e.g. current GOMAXPROCS: 12

    // setting GOMAXPROCS to 1 creates only one P
    // → all goroutines run alternately on a single OS thread (no true parallelism)
    runtime.GOMAXPROCS(1)
}
  • GOMAXPROCS=1: since there is one P, goroutines are configured concurrently but only one runs at a time. Useful for debugging or reproducing race conditions
  • GOMAXPROCS=N: up to N goroutines can run simultaneously. Generally it's recommended to keep the default (number of CPU cores)

7. Goroutine Leak

7.1 What Is a Goroutine Leak?

A state where a goroutine is no longer needed but stays alive without terminating is called a goroutine leak. It occupies memory and is not subject to GC, so memory usage keeps increasing over time.

7.2 Common Causes

  1. Channel waiting: blocking forever on a channel that nobody receives from / sends to
  2. Infinite loop: a goroutine with no termination condition
  3. Not using context: a goroutine that runs without a cancellation signal

7.3 Leak Example

In the code below, leakyFunc creates an unbuffered channel and sends a value from a goroutine. But if the caller does not receive from the channel, the goroutine blocks forever at ch <- 42. This goroutine is not reclaimed even by GC.

func TestGoroutineLeak(t *testing.T) {
    initialCount := runtime.NumGoroutine()

    leakyFunc := func() <-chan int {
        ch := make(chan int)
        go func() {
            ch <- 42 // blocks forever because nobody receives
        }()
        return ch
    }

    _ = leakyFunc() // receives the channel but doesn't use it → leak!

    time.Sleep(50 * time.Millisecond)
    leakedCount := runtime.NumGoroutine()
    // initial: 2, after leak: 3 → the goroutine count increased
}

If this pattern is called repeatedly, goroutines keep piling up and memory usage increases without bound.

7.4 Preventing Leaks with Context

To solve the problem above, a goroutine must be able to terminate itself upon receiving an external signal. Using context.Context's cancellation mechanism, you can send a termination signal to the goroutine.

func TestGoroutineLeakPrevention_WithContext(t *testing.T) {
    safeFunc := func(ctx context.Context) <-chan int {
        ch := make(chan int, 1) // changed to a buffered channel → send does not block
        go func() {
            defer close(ch)
            select {
            case ch <- 42:       // deliver the value normally
            case <-ctx.Done():   // terminate the goroutine when context is canceled
                return
            }
        }()
        return ch
    }

    ctx, cancel := context.WithCancel(context.Background())
    ch := safeFunc(ctx)
    _ = ch

    cancel() // canceling the context cleans up the goroutine
}

The improvements can be summarized as follows:

  • buffered channel (make(chan int, 1)): send doesn't block even without a receiver, so the goroutine doesn't get stuck
  • select + ctx.Done(): when the context is canceled, the ctx.Done() channel closes and the goroutine terminates via return
  • defer close(ch): the channel is cleaned up together when the goroutine terminates

Key principle: when creating a goroutine, always secure a termination path. Use context, a done channel, close, and the like.

8. Quiz

If you've read this far, these are questions you can answer. Pick an answer and the explanation shows up right away.

- type: mcq
  q: "Which correctly describes the difference between concurrency and parallelism?"
  choices: ["Concurrency is an execution mode that requires several CPUs", "Parallelism is the concept of composing tasks into small units", "Concurrency is about composition, parallelism about execution", "They are just different words that in practice mean the same"]
  answer: 2
  explain: "Concurrency is a structure for dealing with multiple tasks at once — it is about task composition — while parallelism is actually executing multiple tasks at the same time. Concurrency is possible even on a single CPU, but parallelism requires multiple CPUs. Design a Go program to be concurrent and the runtime takes care of leveraging parallelism. (Section 1)"

- type: ox
  q: "Go's concurrency philosophy is \"communicate by sharing memory,\" which is why it recommends mutexes over channels."
  answer: false
  explain: "The Go Proverb says the opposite: \"Do not communicate by sharing memory; instead, share memory by communicating.\" Go recommends passing data through channels. Because ownership of the data is transferred through the channel, only one goroutine accesses the data at a given moment. (Section 2.2)"

- type: mcq
  q: "When shared state makes locks complex, what does the article recommend?"
  choices: ["Add more goroutines so the waiting gets spread out", "Reconsider the design itself rather than pushing on", "Split the locks finer to shrink the contended region", "Grow the channel buffer size to ease the bottleneck"]
  answer: 1
  explain: "The article lists \"so much shared state that locks become complex\" as a case of over-engineering, and says to reconsider the design. Concurrency adds complexity by itself, so if it becomes hard to debug, simple sequential processing is better. (Section 3.2)"

- type: code
  q: "Why does the assert in this test pass?"
  lang: go
  code: |
    var completed atomic.Bool

    go func() {
        time.Sleep(100 * time.Millisecond)
        completed.Store(true)
    }()

    assert.False(t, completed.Load())
  choices: ["An atomic.Bool value is permanently fixed to false", "A function with the go keyword is ignored, never run", "time.Sleep permanently halts the whole program here", "Without waiting, the read happens before it completes"]
  answer: 3
  explain: "Load() is read immediately after launching the goroutine, so a value that only becomes true 100ms later is still false. When the main goroutine terminates, the entire program terminates regardless of the other goroutines, so if you need the result you must wait explicitly. (Section 4.4)"

- type: blank
  q: "A goroutine's initial stack size is about ___, and unlike an OS thread's fixed ~1MB it grows dynamically."
  answer: ["2KB", "~2KB", "2 KB"]
  explain: "A goroutine's initial stack is ~2KB and grows dynamically as needed. Compared with an OS thread's fixed ~1MB, creation is very cheap, so hundreds of thousands are possible, and context switching is fast because only 3 registers are involved. (Section 4.2)"

- type: code
  q: "What happens if you delete the wg.Wait() line from this code?"
  lang: go
  code: |
    var completed atomic.Bool
    var wg sync.WaitGroup

    wg.Add(1)
    go func() {
        defer wg.Done()
        time.Sleep(50 * time.Millisecond)
        completed.Store(true)
    }()

    wg.Wait()
    assert.True(t, completed.Load())
  choices: ["The assert fails since nothing waits for the goroutine", "wg.Done holds execution for the 50ms in its place", "wg.Add(1) is there, so the result stays exactly the same", "The compiler rejects this misuse of the WaitGroup"]
  answer: 0
  explain: "wg.Wait() waits until the goroutine completes, which is why the assert sees true. Delete that line and the value that gets set 50ms later is read without waiting, so it is false. To wait for a goroutine to finish you need sync.WaitGroup or a channel. (Section 4.4)"

- type: mcq
  q: "What is the biggest difference between a Go goroutine and a Kotlin coroutine?"
  choices: ["Only goroutines are stackless objects living on the heap", "Goroutines are preemptive, coroutines are cooperative", "Only coroutines can pass messages through a channel", "Goroutines still have the function coloring problem"]
  answer: 1
  explain: "The article points to the scheduling method as the biggest difference. Goroutines are preemptive, so the Go runtime forcibly switches one that holds the CPU for a long time (Go 1.14+), while Kotlin coroutines are cooperative and switch only at suspend points. (Section 5.2)"

- type: mcq
  q: "In the GMP model, what happens when a P's local run queue becomes empty?"
  choices: ["The P is released at once and its bound M shuts down", "The OS kernel picks the next goroutine and hands it over", "It takes a goroutine from another P's queue (work stealing)", "The number of usable Ps is automatically reduced by one"]
  answer: 2
  explain: "When the local run queue is empty, it fetches a goroutine from another P's queue via work stealing. Scheduling is performed by the Go runtime in user space rather than by the OS kernel, so context switching costs much less than with OS threads. (Section 6.1)"

- type: blank
  q: "The function that sets the maximum number of Ps able to run goroutines simultaneously is runtime.___(n), and its default is the CPU core count."
  answer: ["GOMAXPROCS"]
  explain: "It is runtime.GOMAXPROCS(n). Passing 0 returns the current value without changing it. Setting it to 1 means there is only one P, so even though goroutines are configured concurrently, only one runs at a time. (Section 6.2)"

- type: ox
  q: "A goroutine stuck sending on an unbuffered channel that nobody receives from is not reclaimed even by GC."
  answer: true
  explain: "Correct. The leaked goroutine blocks forever at ch <- 42, occupying memory and never becoming subject to GC. Switch to a buffered channel and put ctx.Done() alongside the send in a select to secure a termination path. (Sections 7.3, 7.4)"

9. Summary

ConceptCore
Concurrency vs ParallelismConcurrency is structure, Parallelism is execution
CSP modelindependent processes + message passing
Goroutinecreated with the go keyword, ~2KB stack, tens of thousands possible
GMP modelG(goroutine) + M(OS thread) + P(processor)
GOMAXPROCSthe number of Ps that can run simultaneously (default = number of CPU cores)
Comparison with other languageslanguage built-in, no function coloring problem, preemptive scheduling
Goroutine Leaka goroutine that doesn't terminate → prevent with context/done channel

In the next part, we'll look at the core mechanism for exchanging data between goroutines: the Channel.

10. References

관련 글