สัมภาษณ์เทคนิค Go: Goroutine, Channel และ Concurrency

คำถามสัมภาษณ์เทคนิค Go เกี่ยวกับ goroutine, channel และ concurrency pattern ต่างๆ ตัวอย่างโค้ด ข้อผิดพลาดที่พบบ่อย และคำตอบระดับผู้เชี่ยวชาญสำหรับเตรียมสัมภาษณ์เทคนิค Go ปี 2026

เตรียมสัมภาษณ์เทคนิค Go ครอบคลุม goroutine channel และ concurrency pattern

คำถามสัมภาษณ์ Go เกี่ยวกับ goroutine, channel และ concurrency เป็นหัวข้อที่ท้าทายที่สุดสำหรับผู้สมัครอย่างสม่ำเสมอ ความเข้าใจอย่างลึกซึ้งในแนวคิดเหล่านี้คือสิ่งที่แยกวิศวกร Go ระดับอาวุโสออกจากผู้ที่ยังเรียนรู้ภาษาอยู่ คู่มือนี้รวบรวมคำถามที่ผู้สัมภาษณ์มักถามในปี 2026 พร้อมตัวอย่างโค้ดระดับ production และเหตุผลเบื้องหลังแต่ละคำตอบ

ผู้สัมภาษณ์ทดสอบอะไรจริงๆ

การสัมภาษณ์ concurrency ของ Go เน้นสามด้าน: การจัดการวงจรชีวิต goroutine, ความหมายของ channel (buffered กับ unbuffered, ชนิด directional) และการประกอบ pattern (fan-out/fan-in, worker pool, context cancellation) การท่องจำ syntax เพียงอย่างเดียวไม่เพียงพอ — ผู้สัมภาษณ์คาดหวังให้ผู้สมัครสามารถวิเคราะห์ race condition และ deadlock ได้

พื้นฐาน Goroutine: คำถามที่ถูกถามเสมอ

รอบแรกของการสัมภาษณ์มักทดสอบว่าผู้สมัครเข้าใจ goroutine จริงๆ หรือไม่ — ไม่ใช่แค่วิธีสร้างมัน

ถ: Goroutine คืออะไร และแตกต่างจาก OS thread อย่างไร?

Goroutine คือฟังก์ชัน concurrent ที่มีน้ำหนักเบา จัดการโดย Go runtime scheduler ไม่ใช่ระบบปฏิบัติการ Go runtime ทำ multiplexing goroutine หลายพันตัวลงบน OS thread จำนวนน้อยโดยใช้โมเดลการจัดตาราง M:N (M goroutine ถูกแมปลงบน N OS thread)

goroutine_basics.gogo
package main

import (
	"fmt"
	"runtime"
	"sync"
)

func main() {
	// Print the number of OS threads available
	fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))

	var wg sync.WaitGroup

	for i := 0; i < 10000; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			// Each goroutine starts with ~2-8KB stack
			// OS threads typically start with 1-8MB
			_ = id
		}(i)
	}

	wg.Wait()
	fmt.Println("All 10,000 goroutines completed")
}

ความแตกต่างหลักที่ควรกล่าวถึงในการสัมภาษณ์: goroutine เริ่มต้นด้วย stack 2-8KB ที่ขยายได้แบบไดนามิก เทียบกับ stack คงที่ 1-8MB ของ OS thread การสลับบริบทระระหว่าง goroutine จัดการใน user space โดย Go scheduler หลีกเลี่ยงการสลับบริบทระระดับ kernel ที่มีต้นทุนสูงของ OS thread ทำให้การสร้าง goroutine 100,000 ตัวเป็นเรื่องปกติ ในขณะที่ OS thread 100,000 ตัวจะใช้ทรัพยากรระบบจนหมด

ถ: เกิดอะไรขึ้นเมื่อ goroutine เกิด panic?

Panic ที่ไม่ถูก recover ใน goroutine ใดก็ตามจะทำให้โปรแกรมทั้งหมดหยุดทำงาน ต่างจาก exception ใน Java หรือ Python ตรงที่ panic แพร่ขึ้นตาม call stack ของ goroutine ตัวเอง — ไม่ใช่ stack ของ goroutine ที่สร้างมันขึ้นมา วิธีเดียวที่จะดักจับได้คือใช้ recover() ภายใน deferred function ของ goroutine เดียวกัน

panic_recovery.gogo
package main

import "fmt"

func safeGo(fn func()) {
	go func() {
		defer func() {
			if r := recover(); r != nil {
				fmt.Println("recovered from panic:", r)
			}
		}()
		fn() // execute the actual work
	}()
}

func main() {
	safeGo(func() {
		panic("something went wrong")
	})

	// Give goroutine time to complete
	select {}
}

ผู้สัมภาษณ์มองหาความตระหนักรู้ว่าบริการ Go ในสภาพแวดล้อม production จะห่อหุ้มการเริ่มทำงาน goroutine ด้วย recovery pattern ไลบรารีอย่าง errgroup จัดการเรื่องนี้ได้อย่างเรียบร้อยกว่า

ความหมายของ Channel: Buffered, Unbuffered และ Directional

คำถามเกี่ยวกับ channel เปิดเผยว่าผู้สมัครเข้าใจโมเดล concurrency ของ Go จริงๆ หรือแค่ท่องจำ pattern

ถ: ความแตกต่างระหว่าง buffered และ unbuffered channel คืออะไร?

Unbuffered channel (make(chan T)) ต้องการให้ทั้งผู้ส่งและผู้รับพร้อมพร้อมกัน — การส่งจะบล็อกจนกว่า goroutine อื่นจะรับค่า Buffered channel (make(chan T, n)) อนุญาตให้ส่งได้สูงสุด n ค่าโดยไม่บล็อก

channel_semantics.gogo
package main

import "fmt"

func main() {
	// Unbuffered: send blocks until receive is ready
	ch := make(chan string)
	go func() {
		ch <- "hello" // blocks here until main reads
	}()
	msg := <-ch
	fmt.Println(msg)

	// Buffered: send does not block until buffer is full
	buf := make(chan int, 3)
	buf <- 1 // does not block (buffer has space)
	buf <- 2 // does not block
	buf <- 3 // does not block
	// buf <- 4 would block — buffer is full

	fmt.Println(<-buf, <-buf, <-buf) // 1 2 3
}

คำถามต่อเนื่องที่ผู้สัมภาษณ์มักถาม: "เมื่อไหร่ควรเลือกใช้แบบไหน?" Unbuffered channel บังคับการซิงโครไนซ์ — มีประโยชน์เมื่อผู้ส่งต้องรู้ว่าผู้รับประมวลผลค่าแล้ว Buffered channel แยกเวลาของผู้ส่งและผู้รับออกจากกัน — มีประโยชน์สำหรับ work queue หรือ rate limiting ที่ยอมรับความหย่อนได้ระดับหนึ่ง

ถ: เกิดอะไรขึ้นเมื่อปิด channel?

การปิด channel ส่งสัญญาณว่าไม่มีค่าจะถูกส่งเข้ามาอีก การ receive จาก channel ที่ปิดแล้วจะคืนค่าทันทีพร้อม zero value การส่งค่าไปยัง channel ที่ปิดแล้วจะทำให้เกิด panic ลูป range บน channel จะออกโดยอัตโนมัติเมื่อ channel ถูกปิด

close_channel.gogo
package main

import "fmt"

func producer(ch chan<- int, count int) {
	for i := 0; i < count; i++ {
		ch <- i
	}
	close(ch) // signal: no more values
}

func main() {
	ch := make(chan int, 5)
	go producer(ch, 5)

	// range exits automatically when channel closes
	for val := range ch {
		fmt.Println("received:", val)
	}

	// Reading from closed channel returns zero value + false
	val, ok := <-ch
	fmt.Printf("after close: val=%d, ok=%v\n", val, ok)
}

จุดสำคัญ: มีเพียงผู้ส่งเท่านั้นที่ควรปิด channel ไม่ใช่ผู้รับ การปิด channel ที่ goroutine อื่นยังเขียนอยู่จะทำให้เกิด panic

พร้อมที่จะพิชิตการสัมภาษณ์ Go แล้วหรือยังครับ?

ฝึกฝนด้วยตัวจำลองแบบโต้ตอบ, flashcards และแบบทดสอบเทคนิคครับ

คำสั่ง Select: การ Multiplexing Channel

ถ: select ทำงานอย่างไร และเกิดอะไรเมื่อหลาย case พร้อมพร้อมกัน?

คำสั่ง select จะบล็อกจนกว่าหนึ่งในการดำเนินการ channel สามารถดำเนินการต่อได้ เมื่อหลาย case พร้อมพร้อมกัน Go จะสุ่มเลือก — เพื่อป้องกันไม่ให้ case ใดถูกละเลย

select_multiplex.gogo
package main

import (
	"context"
	"fmt"
	"time"
)

func fetchFromAPI(ctx context.Context, url string) (string, error) {
	resultCh := make(chan string, 1)
	errCh := make(chan error, 1)

	go func() {
		// Simulate API call
		time.Sleep(200 * time.Millisecond)
		resultCh <- fmt.Sprintf("data from %s", url)
	}()

	select {
	case result := <-resultCh:
		return result, nil
	case err := <-errCh:
		return "", err
	case <-ctx.Done():
		// Context cancelled or timed out
		return "", ctx.Err()
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
	defer cancel()

	result, err := fetchFromAPI(ctx, "https://api.example.com/data")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(result)
}

ผู้สัมภาษณ์ทดสอบสองอย่างด้วย select: ความเข้าใจกฎการเลือกแบบสุ่ม และความสามารถในการรวม channel กับ context.Context สำหรับ pattern timeout และการยกเลิก

Concurrency Pattern ที่พบบ่อยในการสัมภาษณ์

การสัมภาษณ์ Go ระดับ senior แทบทุกครั้งมักมีคำถามเกี่ยวกับการเขียน pattern ต่อไปนี้จากศูนย์

Pattern Fan-Out/Fan-In

ถ: เขียน pipeline fan-out/fan-in ที่ประมวลผลรายการแบบขนานกัน

Fan-out กระจายงานไปยังหลาย goroutine Fan-in รวบรวมผลลัพธ์จากหลาย goroutine เข้าสู่ channel เดียว

fanout_fanin.gogo
package main

import (
	"fmt"
	"sync"
)

// generator produces values on a channel
func generator(nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		for _, n := range nums {
			out <- n
		}
		close(out)
	}()
	return out
}

// square reads from input, squares each value
func square(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		for n := range in {
			out <- n * n
		}
		close(out)
	}()
	return out
}

// fanIn merges multiple channels into one
func fanIn(channels ...<-chan int) <-chan int {
	var wg sync.WaitGroup
	merged := make(chan int)

	for _, ch := range channels {
		wg.Add(1)
		go func(c <-chan int) {
			defer wg.Done()
			for val := range c {
				merged <- val
			}
		}(ch)
	}

	go func() {
		wg.Wait()
		close(merged) // close after all inputs are drained
	}()

	return merged
}

func main() {
	in := generator(2, 3, 4, 5, 6)

	// Fan out: two goroutines reading from same channel
	c1 := square(in)
	c2 := square(in)

	// Fan in: merge results
	for result := range fanIn(c1, c2) {
		fmt.Println(result)
	}
}

Insight หลักที่ผู้สัมภาษณ์ต้องการ: channel generator ถูกแชร์ระหว่าง c1 และ c2 ดังนั้นแต่ละค่าจะถูกประมวลผลโดย worker เพียงตัวเดียว (ไม่ซ้ำกัน) ฟังก์ชัน fanIn ใช้ WaitGroup เพื่อทราบว่า input channel ทั้งหมดถูกอ่านจนหมดก่อนปิด channel รวม

Worker Pool ด้วย errgroup

ถ: เขียน bounded worker pool ที่มีการจัดการ error อย่างไร?

Package golang.org/x/sync/errgroup (ส่วนหนึ่งของ Go extended standard library) แก้ปัญหานี้ได้อย่างเรียบร้อย โดยจัดการวงจรชีวิต goroutine รวบรวม error แรก และทำงานร่วมกับ context สำหรับการยกเลิก

worker_pool.gogo
package main

import (
	"context"
	"fmt"
	"golang.org/x/sync/errgroup"
)

func processItem(ctx context.Context, id int) error {
	// Check for cancellation before heavy work
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}

	if id == 7 {
		return fmt.Errorf("failed to process item %d", id)
	}
	fmt.Printf("processed item %d\n", id)
	return nil
}

func main() {
	g, ctx := errgroup.WithContext(context.Background())
	g.SetLimit(3) // maximum 3 concurrent goroutines

	for i := 0; i < 10; i++ {
		id := i
		g.Go(func() error {
			return processItem(ctx, id)
		})
	}

	// Wait blocks until all goroutines finish
	// Returns the first non-nil error
	if err := g.Wait(); err != nil {
		fmt.Println("pipeline error:", err)
	}
}

ตั้งแต่ Go 1.24 (เวอร์ชัน stable ล่าสุด ณ ต้นปี 2026) pattern นี้ยังคงเป็นวิธีที่แนะนำ เมธอด SetLimit ถูกเพิ่มใน Go 1.20 และทำให้ไม่ต้องเขียนการจำกัด concurrency แบบ semaphore ด้วยตัวเองอีกต่อไป

Race Condition และ sync Primitive

ถ: ตรวจจับและป้องกัน race condition ใน Go อย่างไร?

Go มี race detector ในตัวที่เปิดใช้งานด้วย flag -race เครื่องมือนี้ตรวจจับการเข้าถึง shared memory แบบ concurrent ที่ไม่ซิงโครไนซ์กันในขณะ runtime

race_condition.gogo
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

// BAD: race condition — do not use in production
func unsafeCounter() int {
	counter := 0
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter++ // DATA RACE: concurrent read/write
		}()
	}
	wg.Wait()
	return counter // result is non-deterministic
}

// GOOD: atomic operations for simple counters
func atomicCounter() int64 {
	var counter atomic.Int64
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter.Add(1) // thread-safe atomic increment
		}()
	}
	wg.Wait()
	return counter.Load() // always 1000
}

// GOOD: mutex for complex shared state
type SafeMap struct {
	mu sync.RWMutex
	data map[string]int
}

func (m *SafeMap) Set(key string, val int) {
	m.mu.Lock()         // exclusive lock for writes
	defer m.mu.Unlock()
	m.data[key] = val
}

func (m *SafeMap) Get(key string) (int, bool) {
	m.mu.RLock()         // shared lock for reads
	defer m.mu.RUnlock()
	v, ok := m.data[key]
	return v, ok
}

func main() {
	fmt.Println("unsafe:", unsafeCounter())  // unpredictable
	fmt.Println("atomic:", atomicCounter())  // always 1000
}

คำตอบสัมภาษณ์ควรครอบคลุมสามกลยุทธ์การซิงโครไนซ์: sync.Mutex / sync.RWMutex สำหรับ shared state ที่ซับซ้อน, sync/atomic สำหรับ counter และ flag จำนวนเล็ก, และ channel สำหรับการสื่อสารระหว่าง goroutine ("share memory by communicating, don’t communicate by sharing memory") การรัน go test -race ./... ควรเป็นส่วนหนึ่งของทุก CI pipeline

Context และ Pattern การยกเลิก

ถ: อธิบายว่า context.Context ควบคุมวงจรชีวิต goroutine อย่างไร

Package context มีกลไกสำหรับส่งต่อสัญญาณการยกเลิก deadline และค่าต่างๆ ในระดับ request ข้ามขอบเขตของ goroutine ทุก goroutine ที่ทำงานยาวนานควรรับ context.Context เป็นพารามิเตอร์ตัวแรก

context_cancellation.gogo
package main

import (
	"context"
	"fmt"
	"time"
)

// worker simulates a long-running task
func worker(ctx context.Context, id int, results chan<- string) {
	select {
	case <-time.After(time.Duration(id*100) * time.Millisecond):
		results <- fmt.Sprintf("worker %d: done", id)
	case <-ctx.Done():
		results <- fmt.Sprintf("worker %d: cancelled (%v)", id, ctx.Err())
	}
}

func main() {
	// Parent context with 250ms deadline
	ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
	defer cancel()

	results := make(chan string, 5)

	// Launch 5 workers with increasing durations
	for i := 1; i <= 5; i++ {
		go worker(ctx, i, results)
	}

	// Collect all results
	for i := 0; i < 5; i++ {
		fmt.Println(<-results)
	}
}

Worker 1 และ 2 ทำงานเสร็จภายใน deadline 250ms Worker 3, 4 และ 5 ได้รับสัญญาณยกเลิกผ่าน ctx.Done() Pattern นี้เป็นพื้นฐานสำหรับการสร้าง HTTP server และ microservice ที่มีความทนทานใน Go — ทุก request handler จะรับ context ที่ส่งต่อสัญญาณยกเลิกเมื่อ client ตัดการเชื่อมต่อ

กับดักที่พบบ่อยในสัมภาษณ์

ห้ามเก็บ context.Context ไว้ใน field ของ struct เอกสารอ้างอิงของ Go ระบุชัดเจนว่า: "Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it." ผู้สัมภาษณ์ทดสอบเรื่องนี้เพื่อประเมินว่าผู้สมัครปฏิบัติตามแนวทางของ Go หรือไม่

การตรวจจับ Deadlock: คำถามสัมภาษณ์แบบหลอกล่อ

ถ: โค้ดนี้จะเกิด deadlock หรือไม่? เพราะอะไร?

คำถามเรื่อง deadlock เป็นที่นิยมเพราะทดสอบความสามารถในการวิเคราะห์การจัดตาราง goroutine และการดำเนินการ channel

deadlock_example.gogo
package main

func main() {
	ch := make(chan int)
	ch <- 42 // DEADLOCK: unbuffered send with no receiver
	// The main goroutine blocks here forever
	// Go runtime detects this: "fatal error: all goroutines are asleep"
}

วิธีแก้ไขตรงไปตรงมา: ทำ channel เป็นแบบ buffered (make(chan int, 1)) หรือสร้าง goroutine เพื่อรับก่อนส่ง Go runtime ตรวจจับ deadlock เมื่อทุก goroutine ถูกบล็อก — แต่เฉพาะเมื่อ ทุกตัว หยุดทำงาน หากมีแม้แต่ goroutine เดียวที่ยังทำงานอยู่ (เช่น HTTP server ที่ทำงานอยู่เบื้องหลัง) runtime จะไม่ตรวจจับ deadlock บางส่วน

Deadlock บางส่วนมองไม่เห็น

Go runtime ตรวจจับ deadlock ได้เฉพาะเมื่อทุก goroutine ในโปรแกรมถูกบล็อก ในแอปพลิเคชันจริงที่มี HTTP server หรือ background worker goroutine ที่รั่วไหลและเกิด deadlock จะไม่กระตุ้นตัวตรวจจับของ runtime เครื่องมืออย่าง pprof และ goroutine dump (runtime.Stack) จำเป็นสำหรับวิเคราะห์ปัญหาเหล่านี้ในสภาพแวดล้อม production

Pattern ขั้นสูง: การประมวลผลแบบ Concurrent พร้อม Rate Limiting

ถ: เขียน rate-limited concurrent API call อย่างไร?

คำถามนี้ทดสอบความสามารถในการรวม concurrency primitive หลายตัวเข้าเป็นโซลูชันที่สมบูรณ์

rate_limited.gogo
package main

import (
	"context"
	"fmt"
	"sync"
	"time"
)

// RateLimiter controls concurrent and temporal access
type RateLimiter struct {
	semaphore chan struct{}   // limits concurrency
	ticker    *time.Ticker    // limits rate
}

func NewRateLimiter(maxConcurrent int, interval time.Duration) *RateLimiter {
	return &RateLimiter{
		semaphore: make(chan struct{}, maxConcurrent),
		ticker:    time.NewTicker(interval),
	}
}

func (rl *RateLimiter) Execute(ctx context.Context, fn func() error) error {
	// Wait for rate limit tick
	select {
	case <-rl.ticker.C:
	case <-ctx.Done():
		return ctx.Err()
	}

	// Acquire concurrency slot
	select {
	case rl.semaphore <- struct{}{}:
	case <-ctx.Done():
		return ctx.Err()
	}

	defer func() { <-rl.semaphore }() // release slot
	return fn()
}

func main() {
	rl := NewRateLimiter(3, 100*time.Millisecond)
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	var wg sync.WaitGroup
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			err := rl.Execute(ctx, func() error {
				fmt.Printf("[%v] processing %d\n", time.Now().Format("04:05.000"), id)
				time.Sleep(150 * time.Millisecond) // simulate work
				return nil
			})
			if err != nil {
				fmt.Printf("item %d: %v\n", id, err)
			}
		}(i)
	}
	wg.Wait()
}

Pattern นี้รวม semaphore ที่ใช้ channel (จำกัด concurrency) กับ ticker (จำกัดอัตรา) Double select ที่ตรวจสอบ context รับประกันการ shutdown แบบ graceful คำตอบระดับ production เช่นนี้คือสิ่งที่แยกผู้สมัครระดับ senior ออกจากคนอื่น

เริ่มฝึกซ้อมเลย!

ทดสอบความรู้ของคุณด้วยตัวจำลองสัมภาษณ์และแบบทดสอบเทคนิคครับ

สรุป

  • Goroutine เป็น thread ระดับ user-space ที่ Go runtime จัดการด้วยการลำดับ M:N; ต้อง recover panic ใน goroutine ที่ถูก spawn เสมอ
  • Unbuffered channel ซิงโครไนซ์ผู้ส่งและผู้รับ; buffered channel แยกเวลา — เลือกตามว่าผู้ส่งต้องการการยืนยันหรือไม่
  • คำสั่ง select ทำ multiplexing การดำเนินการ channel ด้วยการเลือกสุ่มเมื่อหลาย case พร้อม; ใช้ร่วมกับ context.Context สำหรับ timeout
  • Fan-out/fan-in และ worker pool (ผ่าน errgroup.SetLimit) เป็นสอง concurrency pattern ที่ถูกถามบ่อยที่สุด
  • ใช้ sync.Mutex สำหรับ shared state ซับซ้อน, sync/atomic สำหรับ counter ง่ายๆ, และ channel สำหรับการสื่อสารระหว่าง goroutine
  • รัน go test -race ใน CI เสมอเพื่อจับ data race; deadlock บางส่วนต้องใช้ pprof ในการวิเคราะห์
  • ห้ามเก็บ context.Context ใน struct — ส่งเป็นพารามิเตอร์ตัวแรกของฟังก์ชัน
  • Rate limiting ใน Go รวม semaphore channel กับ ticker ห่อหุ้มใน select statement ที่รับรู้ context

เริ่มฝึกซ้อมเลย!

ทดสอบความรู้ของคุณด้วยตัวจำลองสัมภาษณ์และแบบทดสอบเทคนิคครับ

แท็ก

#go
#golang
#interview
#goroutines
#channels
#concurrency

แชร์

บทความที่เกี่ยวข้อง