MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Go 语言协程(Goroutine)的创建、销毁与资源管理

2023-02-171.7k 阅读

Go 语言协程(Goroutine)的创建

简单创建

在 Go 语言中,创建一个协程(Goroutine)非常简单,只需要在调用函数前加上 go 关键字。以下是一个简单的示例:

package main

import (
    "fmt"
    "time"
)

func printNumbers() {
    for i := 1; i <= 5; i++ {
        fmt.Printf("Number: %d\n", i)
        time.Sleep(100 * time.Millisecond)
    }
}

func main() {
    go printNumbers()

    time.Sleep(1 * time.Second)
    fmt.Println("Main function exiting")
}

在上述代码中,printNumbers 函数是一个普通的函数。通过在 main 函数中使用 go printNumbers(),我们创建了一个新的协程来执行 printNumbers 函数。主函数并不会等待这个协程完成,而是继续执行后续代码。为了让主函数等待一段时间以便协程有机会执行,我们使用了 time.Sleep 函数。

带参数的协程创建

协程函数也可以接受参数,就像普通函数一样。以下是一个示例:

package main

import (
    "fmt"
    "time"
)

func printMessage(message string, count int) {
    for i := 0; i < count; i++ {
        fmt.Printf("%d: %s\n", i, message)
        time.Sleep(100 * time.Millisecond)
    }
}

func main() {
    go printMessage("Hello, Goroutine!", 3)

    time.Sleep(500 * time.Millisecond)
    fmt.Println("Main function exiting")
}

这里的 printMessage 函数接受一个字符串和一个整数作为参数。通过 go printMessage("Hello, Goroutine!", 3),我们创建了一个带有参数的协程。

匿名函数作为协程

除了使用具名函数创建协程,我们还可以使用匿名函数来创建协程。这在只需要一次性使用的逻辑场景下非常方便。

package main

import (
    "fmt"
    "time"
)

func main() {
    go func() {
        for i := 1; i <= 3; i++ {
            fmt.Printf("Anonymous Goroutine: %d\n", i)
            time.Sleep(100 * time.Millisecond)
        }
    }()

    time.Sleep(500 * time.Millisecond)
    fmt.Println("Main function exiting")
}

在上述代码中,go 关键字后面紧跟着一个匿名函数定义和调用 func() {... }()。这个匿名函数被作为一个新的协程来执行。

Go 语言协程(Goroutine)的销毁

Go 语言中没有直接销毁协程的机制

与一些其他编程语言不同,Go 语言并没有提供直接销毁或终止一个协程的原生机制。这主要是出于设计哲学的考虑,Go 语言鼓励通过通信来共享数据,而不是共享数据来进行通信。强制终止一个协程可能会导致资源泄漏、数据不一致等问题。例如,一个协程可能正在进行文件写入操作,如果突然被终止,文件可能处于未完成写入的状态,导致数据损坏。

通过通信来结束协程

虽然不能直接销毁协程,但我们可以通过通信的方式让协程自行结束。常用的方式是使用通道(Channel)。

package main

import (
    "fmt"
)

func worker(done chan bool) {
    fmt.Println("Worker started")
    // 模拟一些工作
    for i := 0; i < 5; i++ {
        fmt.Printf("Working: %d\n", i)
    }
    fmt.Println("Worker done")
    done <- true
}

func main() {
    done := make(chan bool)
    go worker(done)

    // 等待 worker 完成
    <-done
    fmt.Println("Main function exiting")
}

在上述代码中,worker 函数接受一个 done 通道。当 worker 完成工作后,它向 done 通道发送一个 true。在 main 函数中,通过 <-done 来阻塞等待 worker 发送信号,这样就实现了让 worker 协程自然结束。

使用 context 包来控制协程生命周期

context 包提供了一种优雅的方式来管理多个协程的生命周期,特别是在处理 HTTP 请求等场景下。context 可以携带截止时间、取消信号等信息,传递给多个协程。

package main

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

func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Worker received cancel signal, exiting")
            return
        default:
            fmt.Println("Worker working")
            time.Sleep(100 * time.Millisecond)
        }
    }
}

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

    go worker(ctx)

    time.Sleep(700 * time.Millisecond)
    fmt.Println("Main function exiting")
}

在上述代码中,context.WithTimeout 创建了一个带有超时时间的 contextworker 函数通过 select 语句监听 ctx.Done() 信号。当超时发生或者 cancel 函数被调用时,ctx.Done() 通道会收到信号,从而让 worker 协程结束。

Go 语言协程(Goroutine)的资源管理

内存资源管理

当一个协程创建时,Go 运行时(runtime)会为其分配一定的栈空间。初始时,栈空间通常比较小(大约 2KB),随着协程执行过程中需求的增加,栈空间会动态增长。当协程结束时,其占用的栈空间会被自动回收。

例如,考虑一个递归的协程函数:

package main

import (
    "fmt"
)

func recursiveGoroutine(n int) {
    if n == 0 {
        return
    }
    fmt.Printf("Recursive call: %d\n", n)
    recursiveGoroutine(n - 1)
}

func main() {
    go recursiveGoroutine(1000)

    // 给协程一些时间执行
    fmt.Scanln()
}

在这个例子中,虽然 recursiveGoroutine 函数可能会递归很多次,但由于栈空间的动态增长机制,协程可以正常运行,并且当协程结束时,栈空间会被回收,不会造成内存泄漏。

文件资源管理

在协程中使用文件资源时,需要注意及时关闭文件以避免资源泄漏。Go 语言的 defer 关键字是管理文件资源的好帮手。

package main

import (
    "fmt"
    "os"
)

func readFile(filePath string) {
    file, err := os.Open(filePath)
    if err != nil {
        fmt.Printf("Error opening file: %v\n", err)
        return
    }
    defer file.Close()

    // 这里可以进行文件读取操作
    fmt.Printf("File %s opened successfully\n", filePath)
}

func main() {
    go readFile("test.txt")

    // 给协程一些时间执行
    fmt.Scanln()
}

readFile 函数中,通过 defer file.Close(),无论函数是正常结束还是因为错误提前返回,文件都会被关闭,从而避免文件资源泄漏。

网络资源管理

在使用网络资源(如 TCP 连接)的协程中,同样需要妥善管理资源。以下是一个简单的 TCP 服务器示例:

package main

import (
    "fmt"
    "net"
)

func handleConnection(conn net.Conn) {
    defer conn.Close()

    // 处理连接,例如读取和写入数据
    buf := make([]byte, 1024)
    n, err := conn.Read(buf)
    if err != nil {
        fmt.Printf("Error reading from connection: %v\n", err)
        return
    }
    fmt.Printf("Received: %s\n", buf[:n])
}

func main() {
    listener, err := net.Listen("tcp", ":8080")
    if err != nil {
        fmt.Printf("Error listening: %v\n", err)
        return
    }
    defer listener.Close()

    for {
        conn, err := listener.Accept()
        if err != nil {
            fmt.Printf("Error accepting connection: %v\n", err)
            continue
        }
        go handleConnection(conn)
    }
}

handleConnection 函数中,通过 defer conn.Close() 确保在处理完连接后关闭 TCP 连接,避免网络资源泄漏。而在 main 函数中,通过 defer listener.Close() 确保在程序结束时关闭监听套接字。

资源竞争与同步

当多个协程同时访问共享资源时,可能会发生资源竞争问题。例如,多个协程同时修改同一个变量。Go 语言提供了多种同步机制来解决这个问题。

使用互斥锁(Mutex)

互斥锁可以保证在同一时间只有一个协程能够访问共享资源。

package main

import (
    "fmt"
    "sync"
)

var (
    counter int
    mu      sync.Mutex
)

func increment(wg *sync.WaitGroup) {
    defer wg.Done()
    mu.Lock()
    counter++
    mu.Unlock()
}

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go increment(&wg)
    }
    wg.Wait()
    fmt.Printf("Final counter value: %d\n", counter)
}

在上述代码中,mu 是一个互斥锁。在 increment 函数中,通过 mu.Lock()mu.Unlock() 来保护对 counter 变量的修改,确保同一时间只有一个协程能够修改 counter,避免资源竞争。

使用读写锁(RWMutex)

读写锁适用于读操作远多于写操作的场景。读操作可以并发进行,而写操作需要独占资源。

package main

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

var (
    data  int
    rwmu  sync.RWMutex
)

func readData(wg *sync.WaitGroup) {
    defer wg.Done()
    rwmu.RLock()
    fmt.Printf("Read data: %d\n", data)
    rwmu.RUnlock()
}

func writeData(wg *sync.WaitGroup) {
    defer wg.Done()
    rwmu.Lock()
    data++
    fmt.Printf("Write data: %d\n", data)
    rwmu.Unlock()
}

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go readData(&wg)
    }

    time.Sleep(100 * time.Millisecond)

    for i := 0; i < 2; i++ {
        wg.Add(1)
        go writeData(&wg)
    }

    wg.Wait()
}

在上述代码中,rwmu 是一个读写锁。读操作使用 rwmu.RLock()rwmu.RUnlock(),允许多个读操作并发进行;写操作使用 rwmu.Lock()rwmu.Unlock(),确保写操作时独占资源,避免读写冲突和写 - 写冲突。

协程池与资源复用

在一些场景下,频繁创建和销毁协程可能会带来性能开销。可以通过实现协程池来复用协程资源。虽然 Go 语言标准库中没有直接提供协程池的实现,但我们可以自己实现一个简单的协程池。

package main

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

type WorkerPool struct {
    Workers int
    TaskQueue chan func()
}

func NewWorkerPool(workers int, taskQueueSize int) *WorkerPool {
    return &WorkerPool{
        Workers: workers,
        TaskQueue: make(chan func(), taskQueueSize),
    }
}

func (wp *WorkerPool) Start() {
    var wg sync.WaitGroup
    for i := 0; i < wp.Workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for task := range wp.TaskQueue {
                task()
            }
        }()
    }
    go func() {
        wg.Wait()
        close(wp.TaskQueue)
    }()
}

func (wp *WorkerPool) Submit(task func()) {
    wp.TaskQueue <- task
}

func main() {
    pool := NewWorkerPool(3, 5)
    pool.Start()

    for i := 0; i < 10; i++ {
        i := i
        pool.Submit(func() {
            fmt.Printf("Task %d is running\n", i)
            time.Sleep(100 * time.Millisecond)
        })
    }

    time.Sleep(500 * time.Millisecond)
    fmt.Println("Main function exiting")
}

在上述代码中,WorkerPool 结构体表示一个协程池,Workers 表示协程池中的协程数量,TaskQueue 是任务队列。Start 方法启动协程池中的协程,这些协程从任务队列中获取任务并执行。Submit 方法用于向任务队列中提交任务。通过这种方式,可以复用协程资源,减少创建和销毁协程的开销。

通过以上对 Go 语言协程的创建、销毁以及资源管理的介绍,希望能帮助你更深入地理解和应用 Go 语言的协程机制,编写出高效、稳定的并发程序。在实际应用中,需要根据具体的需求和场景,合理选择和使用这些技术,以充分发挥 Go 语言在并发编程方面的优势。同时,要注意资源管理和同步问题,避免出现资源泄漏和数据不一致等问题。