Skip to content

Latest commit

 

History

History
959 lines (747 loc) · 32.9 KB

File metadata and controls

959 lines (747 loc) · 32.9 KB

十三、将上下文用于协调

本章介绍相对较新的上下文包及其在并发编程中的使用。它是一个非常强大的工具,它定义了一个独特的接口,用于标准库中的许多不同位置,以及许多第三方软件包中。

本章将介绍以下主题:

  • 理解上下文是什么
  • 研究其在标准库中的使用
  • 创建使用上下文的包

技术要求

本章要求安装 Go 并设置您最喜爱的编辑器。更多信息请参见第三章Go概述。

理解上下文

上下文是在版本 1.7 中进入标准库的一个相对较新的组件。它是 Go 团队内部使用的 goroutines 之间的同步接口,最终成为该语言的核心部分。

接口

包中的主要实体是Context本身,它是一个接口。它只有四种方法:

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}

让我们在这里了解这四种方法:

  • Deadline:返回应该取消上下文的时间,当没有截止日期时返回一个布尔值,即false

  • Done:返回一个空结构的只接收通道,当上下文被取消时发出信号

  • Err:在done通道打开时返回nil;否则,它将返回上下文取消的原因

  • Value:返回与当前上下文的键关联的值,如果该键没有值,则返回nil

与标准库的其他接口相比,上下文有许多方法,这些接口通常有一个或两个方法。其中三个密切相关:

  • Deadline是取消的时间
  • Done上下文结束时发出信号
  • Err返回取消的原因

最后一个方法Value返回与某个键关联的值。包的其余部分是一系列函数,允许您创建不同类型的上下文。让我们看一下组成这个包的各种功能,并看看用于创建和装饰上下文的各种工具。

默认上下文

TODOBackground函数返回context.Context而不需要任何输入参数。返回的值是一个空上下文,但它们的区别只是语义上的。

出身背景

Background是一个空上下文,不会被取消,没有截止日期,也不包含任何值。它主要由main函数用作根上下文或用于测试目的。以下是此上下文的一些示例代码:

func main() {
    ctx := context.Background()
    done := ctx.Done()
    for i :=0; ;i++{
        select {
        case <-done:
            return
        case <-time.After(time.Second):
            fmt.Println("tick", i)
        }
    }
}

此处提供完整示例:https://play.golang.org/p/y_3ip7sdPnx

我们可以看到,在示例的上下文中,循环无限继续,因为上下文从未完成。

待办事项

TODO是另一个空上下文,当上下文的范围不清楚或者上下文的类型还不可用时,应该使用它。其使用方式与Background完全相同。事实上,在引擎盖下,他们是同一件事;区别只是语义上的。如果我们查看源代码,它们的定义完全相同:

var (
    background = new(emptyCtx)
    todo = new(emptyCtx)
)

此代码的源代码可在中找到 https://golang.org/pkg/context/?m=all#pkg-变量

可以使用包的其他功能扩展这些基本上下文。它们将充当装饰器,并为其添加更多功能。

取消、超时和截止日期

我们查看的上下文从未被取消,但该包提供了添加此功能的不同选项。

取消

context.WithCanceldecorator 函数获取一个上下文并返回另一个上下文和一个名为cancel的函数。返回的上下文将是上下文的副本,该上下文具有不同的done通道(标记当前上下文已完成的通道),当父上下文执行或调用cancel函数时,该通道将关闭—无论先发生什么。

在下面的示例中,我们可以看到在调用cancel函数之前等待了几秒钟,程序正确终止。Err的值为context.Canceled变量:

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    time.AfterFunc(time.Second*5, cancel)
    done := ctx.Done()
    for i := 0; ; i++ {
        select {
        case <-done:
            fmt.Println("exit", ctx.Err())
            return
        case <-time.After(time.Second):
            fmt.Println("tick", i)
        }
    }
}

此处提供完整示例:https://play.golang.org/p/fNHLIZL8e0L

最后期限

context.WithDeadline是另一个 decorator,它将时间截止日期指定为time.Time,并将其应用于另一个上下文。如果已经有一个截止日期并且它早于提供的截止日期,那么指定的截止日期将被忽略。如果done通道在截止日期前仍然打开,则自动关闭。

在下面的示例中,我们将截止日期设置为从现在起 5 秒,并在 10 秒后调用cancel。截止日期在取消之前到达,Err返回一个context.DeadlineExceeded错误:

func main() {
    ctx, cancel := context.WithDeadline(context.Background(), 
         time.Now().Add(5*time.Second))
    time.AfterFunc(time.Second*10, cancel)
    done := ctx.Done()
    for i := 0; ; i++ {
        select {
        case <-done:
            fmt.Println("exit", ctx.Err())
            return
        case <-time.After(time.Second):
            fmt.Println("tick", i)
        }
    }
}

此处提供完整示例:https://play.golang.org/p/iyuOmd__CGH

我们可以看到前面的示例的行为与预期完全相同。它将每秒打印几次tick语句,直到达到截止日期并返回错误。

超时

最后一个与取消相关的修饰符是context.WithTimeout,它允许您与上下文一起指定一个time.Duration,并在超过超时时自动关闭done通道。

如果存在活动的截止日期,则新值仅在其早于父项时应用。在上下文定义旁边,我们可以看一个非常相同的示例,得到与截止期示例相同的结果:

func main() {
    ctx, cancel := context.WithTimeout(context.Background(),5*time.Second)
    time.AfterFunc(time.Second*10, cancel)
    done := ctx.Done()
    for i := 0; ; i++ {
        select {
        case <-done:
            fmt.Println("exit", ctx.Err())
            return
        case <-time.After(time.Second):
            fmt.Println("tick", i)
        }
    }
}

此处提供完整示例:https://play.golang.org/p/-Zp63_e0zYD

键和值

context.WithValue函数创建父上下文的副本,该副本具有与指定值关联的给定键。它的作用域在处理时保存与单个请求相关的值,不应用于其他作用域,例如可选函数参数。

密钥应该是可以比较的,最好避免使用string值,因为使用上下文的两个不同包可能会覆盖彼此的值。建议使用用户定义的具体类型,如struct{}

在这里,我们可以看到一个示例,其中我们采用一个基本上下文,并使用一个空结构作为键,为每个 goroutine 添加一个不同的值:

type key struct{}

type key struct{}

func main() {
    ctx, canc := context.WithCancel(context.Background())
    wg := sync.WaitGroup{}
    wg.Add(5)
    for i := 0; i < 5; i++ {
        go func(ctx context.Context) {
            v := ctx.Value(key{})
            fmt.Println("key", v)
            wg.Done()
            <-ctx.Done()
            fmt.Println(ctx.Err(), v)
        }(context.WithValue(ctx, key{}, i))
    }
    wg.Wait()
    canc()
    time.Sleep(time.Second)
}

此处提供完整示例:https://play.golang.org/p/lM61u_QKEW1

我们还可以看到,取消父上下文会取消其他上下文。另一个有效的键类型可以是导出的指针值,即使基础数据为:

type key *int

func main() {
    k := new(key)
    ctx, canc := context.WithCancel(context.Background())
    wg := sync.WaitGroup{}
    wg.Add(5)
    for i := 0; i < 5; i++ {
        go func(ctx context.Context) {
            v := ctx.Value(k)
            fmt.Println("key", v, ctx.Value(new(key)))
            wg.Done()
            <-ctx.Done()
            fmt.Println(ctx.Err(), v)
        }(context.WithValue(ctx, k, i))
    }
    wg.Wait()
    canc()
    time.Sleep(time.Second)
}

此处提供完整示例:https://play.golang.org/p/05XJwWF0-0n

我们可以看到,定义具有相同基础值的键指针不会返回预期值。

标准库中的上下文

现在我们已经介绍了软件包的内容,我们将了解如何在标准软件包或应用程序中使用它们。上下文用于标准包的一些函数和方法,主要是网络包。现在让我们看一下:

  • http.Server将其与Shutdown方法一起使用,以便完全控制超时或取消操作。
  • http.Request允许您使用WithContext方法设置上下文。它还允许您使用Context获取当前上下文。
  • net包中,ListenDialLookup有一个使用Context控制截止日期和超时的版本。
  • database/sql包中,上下文用于停止或超时许多不同的操作。

HTTP 请求

在引入正式包之前,每个与 HTTP 相关的框架都使用自己版本的上下文来存储与 HTTP 请求相关的数据。这导致了碎片化,如果不重写中间件或任何特定绑定代码,就不可能重用处理程序和中间件。

传递作用域值

http.Request中引入的context.Context试图通过定义一个可以在各种处理程序中分配、恢复和使用的接口来解决这个问题。

缺点是上下文不会自动分配给请求,并且上下文值不能回收。这样做应该没有真正好的理由,因为上下文应该存储特定于某个包或范围的数据,并且包本身应该是唯一能够与它们交互的包。

一个好的模式是使用唯一的未报告键类型,并结合辅助函数来获取或设置某个值:

type keyType struct{}

var key = &keyType{}

func WithKey(ctx context.Context, value string) context.Context {
    return context.WithValue(ctx, key, value)
}

func GetKey(ctx context.Context) (string, bool) {
    v := ctx.Value(key)
    if v == nil {
        return "", false
    }
    return v.(string), true
}

在标准库中,上下文请求是唯一使用WithContext方法存储在数据结构中并使用Context方法访问的情况。这样做是为了不破坏现有代码,并保持 Go 1 兼容性的承诺。

此处提供完整示例:https://play.golang.org/p/W6gGp_InoMp

请求取消

当您使用http.Client执行 HTTP 请求时,上下文的一个好用法是取消和超时,它会自动从上下文处理中断。下面的示例正是这样做的:

func main() {
    const addr = "localhost:8080"
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(time.Second * 5)
    })
    go func() {
        if err := http.ListenAndServe(addr, nil); err != nil {
            log.Fatalln(err)
        }
    }()
    req, _ := http.NewRequest(http.MethodGet, "http://"+addr, nil)
    ctx, canc := context.WithTimeout(context.Background(), time.Second*2)
    defer canc()
    time.Sleep(time.Second)
    if _, err := http.DefaultClient.Do(req.WithContext(ctx)); err != nil {
        log.Fatalln(err)
    }
}

上下文取消方法还可用于中断传递给客户端的当前 HTTP 请求。在调用不同端点并返回收到的第一个结果的场景中,最好取消其他请求。

让我们创建一个应用程序,在不同的搜索引擎上运行查询,并从最快的搜索引擎返回结果,取消其他搜索引擎。我们可以创建一个具有唯一端点的 web 服务器,该端点可在 0 到 10 秒内回复:

const addr = "localhost:8080"
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    d := time.Second * time.Duration(rand.Intn(10))
    log.Println("wait", d)
    time.Sleep(d)
})
go func() {
    if err := http.ListenAndServe(addr, nil); err != nil {
        log.Fatalln(err)
    }
}()

我们可以为请求使用可取消的上下文,并结合等待组将其与请求的结尾同步。每个 goroutine 将创建一个请求,并尝试使用通道发送结果。因为我们只对第一个感兴趣,所以我们将使用sync.Once来限制它:

ctx, canc := context.WithCancel(context.Background())
ch, o, wg := make(chan int), sync.Once{}, sync.WaitGroup{}
wg.Add(10)
for i := 0; i < 10; i++ {
    go func(i int) {
        defer wg.Done()
        req, _ := http.NewRequest(http.MethodGet, "http://"+addr, nil)
        if _, err := http.DefaultClient.Do(req.WithContext(ctx)); err != nil {
            log.Println(i, err)
            return
        }
        o.Do(func() { ch <- i })
    }(i)
}
log.Println("received", <-ch)
canc()
log.Println("cancelling")
wg.Wait()

当这个程序运行时,我们将看到其中一个请求成功完成并被发送到通道,而其他请求要么被取消,要么被忽略。

HTTP 服务器

net/http包有多种上下文用途,包括停止侦听器或作为请求的一部分。

关闭

http.Server允许我们传递关机操作的上下文。这允许我们使用一些上下文功能,例如取消和超时。我们可以使用mux和可取消的上下文定义一个新服务器:

mux := http.NewServeMux()
server := http.Server{
    Addr: ":3000",
    Handler: mux,
}
ctx, canc := context.WithCancel(context.Background())
defer canc()
mux.HandleFunc("/shutdown", func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("OK"))
    canc()
})

我们可以在单独的 goroutine 中启动服务器:

go func() {
    if err := server.ListenAndServe(); err != nil {
        if err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }
}()

当调用 shutdown 端点并调用 cancellation 函数时,上下文将完成。我们可以等待该事件,然后使用另一个具有超时的上下文调用 shutdown 方法:

select {
case <-ctx.Done():
    ctx, canc := context.WithTimeout(context.Background(), time.Second*5)
    defer canc()
    if err := server.Shutdown(ctx); err != nil {
        log.Fatalln("Shutdown:", err)
    } else {
        log.Println("Shutdown:", "ok")
    }
}

这将允许我们在超时时间内有效地终止服务器,之后它将以错误终止。

传递值

服务器中上下文的另一种用法是在不同 HTTP 处理程序之间传播值和取消。让我们看一个例子,其中每个请求都有一个唯一的整数键。我们将使用两个函数,它们与使用整数的值的示例类似。新密钥的生成将通过atomic完成:

type keyType struct{}

var key = &keyType{}

var counter int32

func WithKey(ctx context.Context) context.Context {
    return context.WithValue(ctx, key, atomic.AddInt32(&counter, 1))
}

func GetKey(ctx context.Context) (int32, bool) {
    v := ctx.Value(key)
    if v == nil {
        return 0, false
    }
    return v.(int32), true
}

现在,我们可以定义另一个函数,该函数接受任何 HTTP 处理程序并在必要时创建上下文,并向其中添加密钥:

func AssignKeyHandler(h http.Handler) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        if ctx == nil {
            ctx = context.Background()
        }
        if _, ok := GetKey(ctx); !ok {
            ctx = WithKey(ctx)
        }
        h.ServeHTTP(w, r.WithContext(ctx))
    }
}

通过这样做,我们可以定义一个非常简单的处理程序,为特定根目录下的文件提供服务。此函数将使用上下文中的键正确记录信息。它还将在尝试提供文件之前检查文件是否存在:

func ReadFileHandler(root string) http.HandlerFunc {
    root = filepath.Clean(root)
    return func(w http.ResponseWriter, r *http.Request) {
        k, _ := GetKey(r.Context())
        path := filepath.Join(root, r.URL.Path)
        log.Printf("[%d] requesting path %s", k, path)
        if !strings.HasPrefix(path, root) {
            http.Error(w, "not found", http.StatusNotFound)
            log.Printf("[%d] unauthorized %s", k, path)
            return
        }
        if stat, err := os.Stat(path); err != nil || stat.IsDir() {
            http.Error(w, "not found", http.StatusNotFound)
            log.Printf("[%d] not found %s", k, path)
            return
        }
        http.ServeFile(w, r, path)
        log.Printf("[%d] ok: %s", k, path)
    }
}

我们可以组合这些处理程序来提供来自不同文件夹(如主用户或临时目录)的内容:

home, err := os.UserHomeDir()
if err != nil {
    log.Fatal(err)
}
tmp := os.TempDir()
mux := http.NewServeMux()
server := http.Server{
    Addr: ":3000",
    Handler: mux,
}

mux.Handle("/tmp/", http.StripPrefix("/tmp/", AssignKeyHandler(ReadFileHandler(tmp))))
mux.Handle("/home/", http.StripPrefix("/home/", AssignKeyHandler(ReadFileHandler(home))))
if err := server.ListenAndServe(); err != nil {
    if err != http.ErrServerClosed {
        log.Fatal(err)
    }
}

我们正在使用http.StipPrefix删除路径的第一部分并获得相对路径,然后将其传递给下面的处理程序。生成的服务器将使用上下文在处理程序之间传递键值–这允许我们创建另一个类似的处理程序,并使用AssignKeyHandler函数包装处理程序和GetKey(r.Context())访问处理程序中的键。

TCP 拨号

网络软件包提供与上下文相关的功能,例如在拨号或收听传入连接时取消拨号。它允许我们在拨号连接时使用上下文的超时和取消功能。

取消连接

为了测试 TCP 连接中上下文的使用情况,我们可以使用 TCP 服务器创建 goroutine,该服务器将在启动侦听器之前等待一段时间:

addr := os.Args[1]
go func() {
    time.Sleep(time.Second)
    listener, err := net.Listen("tcp", addr)
    if err != nil {
        log.Fatalln("Listener:", addr, err)
    }
    c, err := listener.Accept()
    if err != nil {
        log.Fatalln("Listener:", addr, err)
    }
    defer c.Close()
}()

我们可以使用超时时间低于服务器等待时间的上下文。为了在拨号操作中使用上下文,我们必须使用net.Dialer

ctx, canc := context.WithTimeout(context.Background(),   
    time.Millisecond*100)
defer canc()
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", os.Args[1])
if err != nil {
    log.Fatalln("-> Connection:", err)
}
log.Println("-> Connection to", os.Args[1])
conn.Close()

应用程序将尝试短时间连接,但最终会在上下文过期时放弃,并返回错误。

在您希望从一系列端点建立单个连接的情况下,上下文取消将是一个完美的用例。所有连接尝试都将共享相同的上下文,正确拨号的第一个连接将调用取消,停止其他尝试。我们将创建一台正在侦听我们将尝试调用的地址之一的服务器:

list := []string{
    "localhost:9090",
    "localhost:9091",
    "localhost:9092",
}
go func() {
    listener, err := net.Listen("tcp", list[0])
    if err != nil {
        log.Fatalln("Listener:", list[0], err)
    }
    time.Sleep(time.Second * 5)
    c, err := listener.Accept()
    if err != nil {
        log.Fatalln("Listener:", list[0], err)
    }
    defer c.Close()
}()

然后,我们可以尝试拨打所有三个地址,并在其中一个连接后立即取消上下文。我们将使用WaitGroup与 goroutines 的结尾同步:

ctx, canc := context.WithTimeout(context.Background(), time.Second*10)
defer canc()
wg := sync.WaitGroup{}
wg.Add(len(list))
for _, addr := range list {
    go func(addr string) {
        defer wg.Done()
        conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr)
        if err != nil {
            log.Println("-> Connection:", err)
            return
        }
        log.Println("-> Connection to", addr, "cancelling context")
        canc()
        conn.Close()
    }(addr)
}
wg.Wait()

我们将在这个程序的输出中看到一个连接成功,接着是另一个尝试的取消错误。

数据库操作

我们在这本书中并没有讨论sql/database包,但为了完整起见,值得一提的是它也使用了上下文。它的大多数操作都与上下文对应,例如:

  • 开始新交易
  • 执行查询
  • ping 数据库
  • 准备查询

标准库中使用上下文的包到此结束。接下来,我们将尝试使用上下文构建一个包,以允许该包的用户取消请求。

实验包

在使用上下文的实验包中,一个值得注意的例子是我们已经看过的——信号量。既然我们已经更好地理解了上下文的作用,那么应该很清楚为什么 acquire 操作也将上下文作为参数。

创建应用程序时,我们可以提供一个超时或取消的上下文,并相应地采取行动:

func main() {
    s := semaphore.NewWeighted(int64(5))
    ctx, canc := context.WithTimeout(context.Background(), time.Second)
    defer canc()
    wg := sync.WaitGroup{}
    wg.Add(20)
    for i := 0; i < 20; i++ {
        go func(i int) {
            defer wg.Done()
            if err := s.Acquire(ctx, 1); err != nil {
                fmt.Println(i, err)
                return
            }
            go func(i int) {
                fmt.Println(i)
                time.Sleep(time.Second / 2)
                s.Release(1)
            }(i)
        }(i)
    }
    wg.Wait()
}

运行此应用程序将显示信号量是在第一秒钟获得的,但在那之后,上下文过期,所有剩余的操作都失败。

应用程序中的上下文

context.Context是集成到您的软件包或应用程序中的完美工具,如果它有可能需要很长时间的操作,并且用户可以取消它们,或者如果它们有时间限制,例如超时或截止日期。

避免的事情

尽管 Go 团队已经明确了上下文范围,但开发人员一直在以各种方式使用它——有些方式不如其他方式正统。让我们看看其中的一些,还有哪些替代方案,而不是求助于上下文。

作为键的错误类型

要避免的第一种做法是使用内置类型作为键。这是有问题的,因为它们可以被覆盖,因为具有相同内置值的两个接口被认为是相同的,如以下示例所示:

func main() {
    var a interface{} = "request-id"
    var b interface{} = "request-id"
    fmt.Println(a == b)

    ctx := context.Background()
    ctx = context.WithValue(ctx, a, "a")
    ctx = context.WithValue(ctx, b, "b")
    fmt.Println(ctx.Value(a), ctx.Value(b))
}

此处提供完整示例:https://play.golang.org/p/2W3noYQP5eh

第一条打印指令输出true,由于按键按值进行比较,第二次赋值将第一次赋值阴影化,导致两个按键的值相同。一个可能的解决方案是使用空的 struct 自定义类型,或指向内置值的未报告指针。

传递参数

您可能需要在一堆函数调用中走很长的路。一个非常诱人的解决方案是使用上下文存储该值,并仅在需要它的函数中调用它。隐藏应该显式传递的必需参数通常不是一个好主意。它会导致可读性较差的代码,因为它无法明确哪些因素会影响特定函数的执行。

将函数向下传递到堆栈中仍然更好。如果参数列表变得太长,那么可以将其分组到一个或多个结构中,以便更具可读性。

让我们看一下以下函数:

func SomeFunc(ctx context.Context, 
    name, surname string, age int, 
    resourceID string, resourceName string) {}

参数可按以下方式分组:

type User struct {
    Name string
    Surname string
    Age int
}

type Resource struct {
    ID string
    Name string
}

func SomeFunc(ctx context.Context, u User, r Resource) {}

可选参数

上下文应该用来传递可选参数,也可以用作一种包罗万象的方式,比如 Pythonkwargs或 JavaScriptarguments。使用上下文作为行为的替代品可能会产生很大的问题,因为它可能会导致变量的阴影,就像我们在context.WithValue的例子中看到的那样。

这种方法的另一大缺点是隐藏正在发生的事情并使代码更加模糊。当涉及到可选值时,一个更好的方法是使用指向结构参数的指针–这允许您完全避免使用nil传递结构。

假设您有以下代码:

// This function has two mandatory args and 4 optional ones
func SomeFunc(ctx context.Context, arg1, arg2 int, 
    opt1, opt2, opt3, opt4 string) {}

通过使用Optional,您将得到如下内容:

type Optional struct {
    Opt1 string
    Opt2 string
    Opt3 string
    Opt4 string
}

// This function has two mandatory args and 4 optional ones
func SomeFunc(ctx context.Context, arg1, arg2 int, o *Optional) {}

全球的

一些全局变量可以存储在上下文中,以便通过一系列函数调用传递。这通常不是好的做法,因为全局变量在应用程序的每个点都可用,所以使用上下文来存储和调用它们是毫无意义的,也是对资源和性能的浪费。如果您的包中有一些全局变量,您可以使用我们在第 12 章与同步和原子的同步中看到的单例模式,允许从包或应用程序的任何点访问它们。

使用上下文构建服务

我们现在将关注如何创建支持上下文使用的包。这将有助于我们总结到目前为止所学的并发性知识。我们将尝试创建一个利用通道、goroutine、同步和上下文的并发文件搜索。

主界面和使用

包的签名将包括上下文、根文件夹、搜索项和两个可选参数:

  • 在内容中搜索:将在文件内容中查找字符串,而不是名称
  • 排除列表:不搜索具有所选名称的文件

该函数将如下所示:

type Options struct {
    Contents bool
    Exclude []string
}

func FileSearch(ctx context.Context, root, term string, o *Options)

因为它应该是一个并发函数,所以返回类型可以是一个结果通道,它可以是一个错误,也可以是文件中的一系列匹配项。由于我们可以搜索内容的名称,后者可能有多个匹配项:

type Result struct {
    Err error
    File string
    Matches []Match
}

type Match struct {
    Line int
    Text string
}

上一个函数将返回一个Result类型的仅接收通道:

func FileSearch(ctx context.Context, root, term string, o *Options) <-chan Result

在此,此函数将一直从通道接收值,直到关闭:

for r := range FileSearch(ctx, directory, searchTerm, options) {
    if r.Err != nil {
        fmt.Printf("%s - error: %s\n", r.File, r.Err)
        continue
    }
    if !options.Contents {
        fmt.Printf("%s - match\n", r.File)
        continue
    }
    fmt.Printf("%s - matches:\n", r.File)
    for _, m := range r.Matches {
        fmt.Printf("\t%d:%s\n", m.Line, m.Text)
    }
}

出入境点

结果通道应该通过取消上下文或结束搜索来关闭。由于一个通道不能关闭两次,我们可以使用sync.Once来避免第二次关闭通道。为了跟踪正在运行的 goroutine,我们可以使用sync.Waitgroup

ch, wg, once := make(chan Result), sync.WaitGroup{}, sync.Once{}
go func() {
    wg.Wait()
    fmt.Println("* Search done *")
    once.Do(func() {
        close(ch)
    })
}()
go func() {
    <-ctx.Done()
    fmt.Println("* Context done *")
    once.Do(func() {
        close(ch)
    })
}()

我们可以为每个文件启动一个 goroutine,这样我们就可以定义一个私有函数,将其用作入口点,然后递归地将其用于子目录:

func fileSearch(ctx context.Context, ch chan<- Result, wg *sync.WaitGroup, file, term string, o *Options)

主导出函数将通过向等待组添加值开始。然后,它将启动私有函数,并将其作为异步进程启动:

wg.Add(1)
go fileSearch(ctx, ch, &wg, root, term, o)

每个fileSearch应该做的最后一件事是调用WaitGroup.Done来标记当前文件的结尾。

排除列表

私有函数将在完成使用Done方法之前减少等待组计数器。。除此之外,它应该做的第一件事是检查文件名,以便在排除列表中可以跳过它:

defer wg.Done()
_, name := filepath.Split(file)
if o != nil {
    for _, e := range o.Exclude {
        if e == name {
            return
        }
    }
}

如果不是这样,我们可以使用os.Stat检查当前文件的信息,如果不成功,则向通道发送错误。由于我们无法通过发送到封闭通道来冒险引起恐慌,因此我们可以检查上下文是否已完成,如果未完成,则发送错误:

info, err := os.Stat(file)
if err != nil {
    select {
    case <-ctx.Done():
        return
    default:
        ch <- Result{File: file, Err: err}
    }
    return
}

处理目录

收到的信息将告诉我们该文件是否为目录。如果它是一个目录,我们可以获得一个文件列表并处理错误,就像我们前面对os.Stat所做的那样。然后,如果上下文尚未完成,我们可以启动另一系列搜索,每个文件一个。以下代码总结了这些操作:

if info.IsDir() {
    files, err := ioutil.ReadDir(file)
    if err != nil {
        select {
        case <-ctx.Done():
            return
        default:
            ch <- Result{File: file, Err: err}
        }
        return
    }
    select {
    case <-ctx.Done():
    default:
        wg.Add(len(files))
        for _, f := range files {
            go fileSearch(ctx, ch, wg, filepath.Join(file, 
        f.Name()), term, o)
        }
    }
    return
}

检查文件名和内容

如果文件是常规文件而不是目录,我们可以根据指定的选项比较文件名或其内容。检查文件名非常简单:

if o == nil || !o.Contents {
    if name == term {
        select {
        case <-ctx.Done():
        default:
            ch <- Result{File: file}
        }
    }
    return
}

如果我们正在搜索内容,则应打开文件:

f, err := os.Open(file)
if err != nil {
    select {
    case <-ctx.Done():
    default:
        ch <- Result{File: file, Err: err}
    }
    return
}
defer f.Close()

然后,我们可以逐行读取文件以搜索所选术语。如果在读取文件时上下文过期,我们将停止所有操作:

scanner, matches, line := bufio.NewScanner(f), []Match{}, 1
for scanner.Scan() {
    select {
    case <-ctx.Done():
        break
    default:
        if text := scanner.Text(); strings.Contains(text, term) {
            matches = append(matches, Match{Line: line, Text: text})
        }
        line++
    }
}

最后,我们可以检查扫描仪的错误。如果没有,并且搜索有结果,我们可以将所有匹配发送到输出通道:

select {
case <-ctx.Done():
    break
default:
    if err := scanner.Err(); err != nil {
        ch <- Result{File: file, Err: err}
        return
    }
    if len(matches) != 0 {
        ch <- Result{File: file, Matches: matches}
    }
}

在不到 200 行中,我们创建了一个并发文件搜索函数,每个文件使用一个 goroutine。它利用通道发送结果和同步原语,以协调操作。

总结

在本章中,我们了解了一个较新的包 context 是关于什么的。我们看到,Context是一个简单的接口,有四个方法,应该用作函数的第一个参数。它的主要作用是处理取消和截止日期,以同步并发操作,并为用户提供取消操作的功能。

我们看到了默认上下文BackgroundTODO如何不允许取消,但是可以使用包的各种功能来扩展它们,以添加超时或取消。我们还讨论了上下文在保存值时的功能,以及如何谨慎使用上下文以避免阴影和其他问题。

然后,我们深入到标准包中,查看上下文已经在哪里使用。这包括请求的 HTTP 功能,可用于值、取消、超时以及服务器关闭操作。我们还通过一个实际示例了解了 TCP 包如何允许我们以类似的方式使用它,我们还列出了数据库包中允许我们使用上下文取消它们的操作。

在使用上下文构建我们自己的功能之前,我们讨论了一些应该避免的用途,从使用错误类型的键到使用上下文传递应该在函数或方法签名中的值。然后,我们继续创建一个搜索文件和内容的函数,使用上三章中关于并发性的知识。

下一章将通过展示最常见的 Go 并发模式及其用法来结束本书的并发部分。这将使我们能够在一些非常常见和有效的配置中总结到目前为止所学到的关于并发性的所有知识。

问题

  1. Go 中的上下文是什么?
  2. 取消、截止日期和超时之间有什么区别?
  3. 通过上下文传递值时的最佳实践是什么?
  4. 哪些标准包已经使用上下文?