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

Go语言中defer的执行顺序探究

2022-10-255.2k 阅读

defer 关键字基础介绍

在Go语言中,defer关键字用于延迟函数的执行。当一个函数执行到defer语句时,并不会立即执行defer后面的函数,而是将其压入一个栈中,等到包含该defer语句的函数执行完毕(无论是正常返回还是发生了恐慌(panic)),再按照后进先出(LIFO)的顺序依次执行这些defer函数。

来看一个简单的示例:

package main

import "fmt"

func main() {
    defer fmt.Println("defer 1")
    defer fmt.Println("defer 2")
    fmt.Println("main function")
}

在上述代码中,首先执行fmt.Println("main function"),输出“main function”。然后,按照defer语句出现的顺序,先将fmt.Println("defer 1")压入栈,再将fmt.Println("defer 2")压入栈。当main函数执行完毕,从栈中弹出fmt.Println("defer 2")执行,输出“defer 2”,接着弹出fmt.Println("defer 1")执行,输出“defer 1”。最终输出结果为:

main function
defer 2
defer 1

defer 在函数返回值相关场景下的执行顺序

defer 与命名返回值

当函数有命名返回值时,defer函数可以访问和修改这些返回值。来看下面的代码:

package main

import "fmt"

func returnWithDefer() (result int) {
    result = 1
    defer func() {
        result++
    }()
    return result
}

在这个函数returnWithDefer中,首先将result赋值为1。然后遇到defer语句,将匿名函数压入栈。接着执行return result语句,此时虽然执行了return,但由于defer的存在,函数并不会立即返回。而是先执行defer中的匿名函数,将result加1,变为2。最后函数返回,返回值为2。

defer 与非命名返回值

对于非命名返回值,defer函数无法直接修改返回值。例如:

package main

import "fmt"

func returnWithoutNamedResult() int {
    var temp = 1
    defer func() {
        temp++
    }()
    return temp
}

在这个函数returnWithoutNamedResult中,声明了一个局部变量temp并赋值为1。defer语句将匿名函数压入栈。执行return temp时,先将temp的值1作为返回值准备好,然后执行defer中的匿名函数,将temp加1变为2,但此时返回值已经确定为1,最终函数返回1。

defer 在循环中的执行顺序

普通循环中的 defer

在普通循环中使用defer时,需要注意其执行顺序。每一次循环遇到defer语句,都会将对应的函数压入栈。例如:

package main

import "fmt"

func deferInLoop() {
    for i := 0; i < 3; i++ {
        defer fmt.Println("defer in loop:", i)
    }
}

在上述代码中,循环3次,每次都会将fmt.Println("defer in loop:", i)压入栈。当deferInLoop函数执行完毕,按照后进先出的顺序执行defer函数,输出结果为:

defer in loop: 2
defer in loop: 1
defer in loop: 0

循环中使用匿名函数包裹 defer

如果在循环中使用匿名函数包裹defer,情况会有所不同。例如:

package main

import "fmt"

func deferInAnonFuncInLoop() {
    for i := 0; i < 3; i++ {
        func() {
            defer fmt.Println("defer in anon func in loop:", i)
        }()
    }
}

这里,每次循环都会创建一个新的匿名函数,并且在匿名函数内部将defer函数压入栈。由于匿名函数在创建时就会执行,所以每次压入栈的defer函数中的i值是当时循环变量i的值。最终输出结果为:

defer in anon func in loop: 0
defer in anon func in loop: 1
defer in anon func in loop: 2

defer 与 panic 和 recover

defer 在 panic 发生时的执行

当函数中发生panic时,defer函数依然会按照后进先出的顺序执行。例如:

package main

import "fmt"

func deferWithPanic() {
    defer fmt.Println("defer 1")
    defer fmt.Println("defer 2")
    panic("something wrong")
    fmt.Println("this line won't be printed")
}

在这个函数中,先遇到两个defer语句,将fmt.Println("defer 1")fmt.Println("defer 2")压入栈。然后发生panic,此时函数并不会立即终止,而是先执行defer函数。按照后进先出的顺序,先输出“defer 2”,再输出“defer 1”,最后程序崩溃并打印出panic信息“something wrong”。

recover 结合 defer 处理 panic

recover函数用于在发生panic时恢复程序的正常执行流程,通常与defer一起使用。例如:

package main

import "fmt"

func recoverWithDefer() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recovered from panic:", r)
        }
    }()
    panic("test panic")
    fmt.Println("this line won't be printed")
}

在这个函数中,defer语句将一个匿名函数压入栈,该匿名函数中使用recover函数。当发生panic时,defer函数执行,recover捕获到panic信息“test panic”,并输出“recovered from panic: test panic”,程序不会崩溃,而是继续执行defer函数之后的代码(这里defer函数之后没有其他代码了)。

defer 的实现原理

在Go语言的运行时环境中,defer是通过一个链表来实现的。当函数执行到defer语句时,会创建一个新的defer结构体,并将其添加到链表头部。这个defer结构体包含了要延迟执行的函数以及相关的参数等信息。

当函数正常返回或者发生panic时,会遍历这个链表,按照后进先出的顺序依次执行链表中的defer函数。在执行defer函数之前,会将链表中的节点从链表中移除。

在Go语言的编译器层面,对于defer语句的处理也比较复杂。编译器会将defer语句转换为特定的指令序列,确保defer函数的正确压栈和执行。例如,在函数开始执行时,会初始化一个defer链表的头部指针。每次遇到defer语句,就会生成代码来创建新的defer结构体,并将其插入到链表头部。

对于defer函数中的闭包部分,编译器也会进行特殊处理,确保闭包能够正确捕获外部变量。例如,在循环中使用defer闭包时,编译器会保证每次闭包捕获到的是当时循环变量的正确值,而不是循环结束后的最终值。

defer 的性能考量

虽然defer给编程带来了很大的便利,但在性能敏感的场景下,也需要考虑其带来的开销。

每次执行defer语句时,都会创建一个新的defer结构体并将其压入链表,这涉及到内存分配和链表操作。对于性能要求极高的函数,如果频繁使用defer,可能会对性能产生一定影响。

例如,在一个高频调用的函数中,如果每次调用都使用defer来关闭文件描述符或者释放资源,虽然代码看起来简洁,但可能会因为频繁的内存分配和链表操作而导致性能下降。

在这种情况下,可以考虑手动管理资源的释放,而不是依赖defer。例如,在打开文件后,在函数结束前手动调用Close方法关闭文件,这样可以避免defer带来的额外开销。

然而,对于大多数应用场景,defer带来的便利远远超过了其性能开销。它能够确保资源的正确释放,避免因为忘记释放资源而导致的内存泄漏等问题。而且Go语言的运行时环境对defer的实现已经进行了优化,在一般情况下,其性能损失是可以接受的。

defer 在并发编程中的应用

defer 在 goroutine 中的执行

在Go语言的并发编程中,defer在每个goroutine中独立执行。例如:

package main

import (
    "fmt"
    "time"
)

func deferInGoroutine() {
    go func() {
        defer fmt.Println("defer in goroutine")
        fmt.Println("goroutine start")
    }()
    time.Sleep(1 * time.Second)
}

在这个函数中,创建了一个新的goroutine。在goroutine内部,先执行fmt.Println("goroutine start"),输出“goroutine start”。然后遇到defer语句,将fmt.Println("defer in goroutine")压入栈。当goroutine执行完毕(这里goroutine执行完打印语句就结束了),按照后进先出的顺序执行defer函数,输出“defer in goroutine”。

利用 defer 确保资源在并发环境下的正确释放

在并发编程中,资源的正确释放尤为重要。例如,在多个goroutine同时访问一个共享资源时,如果某个goroutine获取了资源但没有正确释放,可能会导致其他goroutine无法获取资源,从而产生死锁等问题。

可以利用defer来确保资源在并发环境下的正确释放。例如,使用sync.Mutex来保护共享资源时:

package main

import (
    "fmt"
    "sync"
)

var mu sync.Mutex
var sharedResource int

func safeAccessResource() {
    mu.Lock()
    defer mu.Unlock()
    sharedResource++
    fmt.Println("Accessed shared resource:", sharedResource)
}

在这个函数中,首先调用mu.Lock()获取锁,然后使用defer确保在函数结束时(无论是正常返回还是发生panic)调用mu.Unlock()释放锁。这样可以保证在并发环境下,共享资源的安全访问,避免因为忘记释放锁而导致的死锁问题。

defer 与错误处理

在错误处理函数中使用 defer

在处理错误的函数中,defer可以用来确保一些清理操作的执行。例如,在读取文件时,如果发生错误,需要关闭已经打开的文件:

package main

import (
    "fmt"
    "os"
)

func readFile() {
    file, err := os.Open("nonexistent.txt")
    if err != nil {
        fmt.Println("Error opening file:", err)
        return
    }
    defer file.Close()
    // 这里进行文件读取操作
    // 如果文件读取过程中发生错误,defer 依然会关闭文件
}

在这个函数中,首先尝试打开文件。如果打开文件失败,打印错误信息并返回。如果成功打开文件,使用defer确保在函数结束时关闭文件。这样即使在文件读取过程中发生错误,文件也会被正确关闭,避免资源泄漏。

defer 与多层错误处理

在复杂的程序中,可能会存在多层错误处理。defer在这种情况下依然能够确保清理操作的正确执行。例如:

package main

import (
    "fmt"
    "os"
)

func innerFunction() error {
    file, err := os.Open("nonexistent.txt")
    if err != nil {
        return err
    }
    defer file.Close()
    // 模拟其他可能出错的操作
    return fmt.Errorf("inner function error")
}

func outerFunction() {
    err := innerFunction()
    if err != nil {
        fmt.Println("Outer function error:", err)
    }
}

在这个例子中,innerFunction尝试打开文件并进行一些可能出错的操作。如果打开文件失败,直接返回错误。如果成功打开文件,使用defer确保文件关闭。outerFunction调用innerFunction并处理其返回的错误。无论在innerFunction还是outerFunction中发生错误,innerFunction中的文件都会被正确关闭,因为defer机制不受多层函数调用和错误处理的影响。

defer 在接口方法中的应用

在接口实现方法中使用 defer

当实现接口方法时,defer可以用来确保资源的正确管理。例如,假设有一个Database接口,其中的Query方法需要在执行完毕后关闭数据库连接:

package main

import (
    "fmt"
)

type Database interface {
    Query(query string) (string, error)
}

type MySQLDatabase struct {
    // 数据库连接相关字段
}

func (m *MySQLDatabase) Query(query string) (string, error) {
    // 模拟获取数据库连接
    fmt.Println("Connecting to MySQL database")
    defer func() {
        fmt.Println("Closing MySQL database connection")
    }()
    // 执行查询操作
    return "query result", nil
}

MySQLDatabaseQuery方法实现中,使用defer确保在方法执行完毕后打印关闭数据库连接的信息(实际应用中这里应该是真正的关闭连接操作)。这样可以保证无论查询过程中是否发生错误,数据库连接都会被正确处理。

defer 对接口方法调用顺序的影响

defer在接口方法中的执行顺序依然遵循后进先出原则,并且不会影响接口方法本身的调用顺序。例如:

package main

import (
    "fmt"
)

type Printer interface {
    Print()
}

type TextPrinter struct {
    text string
}

func (t *TextPrinter) Print() {
    defer fmt.Println("defer in TextPrinter.Print")
    fmt.Println("Printing text:", t.text)
}

func main() {
    var p Printer = &TextPrinter{text: "Hello, defer!"}
    p.Print()
}

在这个例子中,TextPrinter实现了Printer接口的Print方法。在Print方法中,defer语句将fmt.Println("defer in TextPrinter.Print")压入栈。当调用p.Print()时,先输出“Printing text: Hello, defer!”,然后在方法结束时,按照后进先出的顺序执行defer函数,输出“defer in TextPrinter.Print”。这里defer的执行顺序不会干扰接口方法的正常调用逻辑。

defer 的一些常见误用及避免方法

循环中误用 defer 导致资源耗尽

在循环中如果不正确使用defer,可能会导致资源耗尽。例如:

package main

import (
    "fmt"
    "os"
)

func wrongUseOfDeferInLoop() {
    for i := 0; i < 1000000; i++ {
        file, err := os.Open("test.txt")
        if err != nil {
            fmt.Println("Error opening file:", err)
            continue
        }
        defer file.Close()
        // 这里没有实际的文件操作,只是打开文件并延迟关闭
    }
}

在这个函数中,每次循环都打开一个文件并使用defer延迟关闭。由于文件描述符是有限的系统资源,如果循环次数过多,可能会导致文件描述符耗尽,程序崩溃。

避免这种误用的方法是,在每次循环中尽快处理完文件操作,并在合适的位置关闭文件,而不是依赖defer。例如:

package main

import (
    "fmt"
    "os"
)

func correctUseOfDeferInLoop() {
    for i := 0; i < 1000000; i++ {
        file, err := os.Open("test.txt")
        if err != nil {
            fmt.Println("Error opening file:", err)
            continue
        }
        // 进行文件操作
        data, err := os.ReadFile("test.txt")
        if err != nil {
            fmt.Println("Error reading file:", err)
        } else {
            fmt.Println("Read data:", string(data))
        }
        file.Close()
    }
}

在这个改进的代码中,在读取完文件数据后,手动调用file.Close()关闭文件,避免了因为defer在循环中不当使用导致的资源耗尽问题。

defer 闭包中对外部变量的错误引用

defer闭包中,如果不正确引用外部变量,可能会得到不符合预期的结果。例如:

package main

import "fmt"

func wrongClosureInDefer() {
    var num = 1
    defer func() {
        fmt.Println("defer closure:", num)
    }()
    num = 2
}

在这个函数中,defer闭包引用了外部变量num。在defer语句执行时,闭包捕获了num的引用。之后num的值被修改为2。当defer函数执行时,输出的是修改后的值“defer closure: 2”,可能与开发者预期的输出“defer closure: 1”不符。

避免这种问题的方法是,在defer语句处将外部变量的值作为参数传递给闭包,这样闭包捕获的是值而不是引用。例如:

package main

import "fmt"

func correctClosureInDefer() {
    var num = 1
    defer func(n int) {
        fmt.Println("defer closure:", n)
    }(num)
    num = 2
}

在这个改进的代码中,将num的值作为参数传递给defer闭包,闭包捕获的是num的值1。当defer函数执行时,输出“defer closure: 1”,符合预期。

通过深入理解defer在各种场景下的执行顺序以及常见的误用情况,开发者能够更加熟练和正确地使用defer关键字,编写出更加健壮和高效的Go语言程序。无论是在资源管理、错误处理还是并发编程等方面,defer都发挥着重要的作用,只要合理运用,就能提高代码的质量和可读性。