我们已经到了本书的最后一章,在这里我们将讨论两种具有并发结构的模式。我们将详细解释每个步骤,以便您可以仔细地遵循示例。
其思想是学习使用惯用的 Go 设计并发应用程序的模式。我们大量使用通道和 goroutine,而不是锁或共享变量。
- 我们将研究一种发展员工队伍的方法。这对于控制执行中 goroutine 的数量非常有用。
- 第二个例子是对观察者模式的重写,我们在第 7 章、行为模式——访客、状态、中介和观察者设计模式中看到了这种模式,它们是用并发结构编写的。在本例中,我们将深入研究并发结构,并了解它们与常用方法的区别。
以前的一些并发方法可能会遇到一个问题,那就是它们的无限上下文。我们不能让应用程序创建无限量的 Goroutines。Goroutines 很轻,但它们执行的工作可能非常繁重。工人联合会帮助我们解决这个问题。
有了一个工作人员池,我们希望限制可用 goroutine 的数量,以便对资源池进行更深入的控制。通过为每个工人创建一个通道,让工人处于空闲或忙碌状态,这很容易实现。这项任务看似艰巨,但根本不是。
创建工作池完全是关于资源控制:CPU、RAM、时间、连接等等。workers pool 设计模式帮助我们完成以下工作:
- 使用配额控制对共享资源的访问
- 为每个应用创建有限数量的 Goroutines
- 为其他并发结构提供更多并行功能
在上一章中,我们了解了如何使用管道。现在,我们将启动数量有限的任务,以便 Go 调度器可以尝试并行处理请求。这里的想法是控制 goroutine 的数量,在应用程序完成后优雅地停止它们,并在没有竞争条件的情况下使用并发结构最大化并行性。
我们将使用的管道与前一章中使用的管道类似,在前一章中,我们生成数字,将它们提高到 2 的幂,并对最终结果求和。在本例中,我们将传递字符串,并向其添加数据和前缀。
在业务术语中,我们需要一些信息来告诉我们,worker 已经处理了一个请求、一个预定义的结尾以及解析为大写的传入数据:
- 使用字符串值(any)发出请求时,该值必须为大写。
- 字符串为大写后,必须向其追加预定义的文本。此文本不应为大写。
- 对于上一个结果,必须在最后一个字符串前加上工作者 ID。
- 结果字符串必须传递给预定义的处理程序。
我们没有讨论如何在技术上做到这一点,只是讨论了业务需要什么。有了完整的描述,我们至少会有工作人员、请求和处理程序。
最开始是一个请求类型。根据描述,它必须包含将进入管道的字符串以及处理程序函数:
// workers_pipeline.go file
type Request struct {
Data interface{}
Handler RequestHandler
}
string
在哪里?我们有一个类型为interface{}
的Data
字段,因此我们可以使用它来传递字符串。通过使用接口,我们可以对string
、int
或struct
数据类型重用此类型。接收方必须知道如何处理传入接口。
Handler
字段具有类型Request
处理程序,我们尚未定义该类型:
type RequestHandler func(interface{})
请求处理程序是任何接受接口作为其第一个参数且不返回任何内容的函数。再次,我们看到了interface{}
,在这里我们通常会看到一个字符串。这是我们前面提到的接收器之一,我们需要将其转换为传入结果。
因此,在发送请求时,我们必须在Data
字段中填入一些值,并实现一个处理程序;例如:
func NewStringRequest(s string, id int, wg *sync.WaitGroup) Request {
myRequest := Request{
Data: "Hello", Handler: func(i interface{})
{
defer wg.Done()
s, ok := i.(string)
if !ok{
log.Fatal("Invalid casting to string")
}
fmt.Println(s)
}
}
}
处理程序是使用闭包定义的。我们再次检查接口的类型(最后我们将调用推迟到Done()
方法)。如果接口不正确,我们只需打印其内容并返回。如果铸造是好的,我们也会打印它们,但这里是我们通常会对操作结果做一些事情的地方;我们必须使用类型转换来检索interface{}
的内容(这是一个字符串)。这必须在管道中的每一个步骤中完成,尽管这会带来一些开销。
现在我们需要一个能够处理Request
类型的类型。可能的实现实际上是无限的,因此最好先定义一个接口:
// worker.go file
type WorkerLauncher interface {
LaunchWorker(in chan Request)
}
WorkerLauncher
接口必须只实现LaunchWorker(chan Request)
方法。实现此接口的任何类型都必须接收一个Request
类型的通道才能满足它。该Request
型通道是管道的单一入口点。
现在,要并行启动 workers 并处理所有可能的传入通道,我们需要类似于调度器的东西:
// dispatcher.go file
type Dispatcher interface {
LaunchWorker(w WorkerLauncher)
MakeRequest(Request)
Stop()
}
Dispatcher
接口可以通过自己的LaunchWorker
方法启动注入式WorkerLaunchers
类型。Dispatcher
接口必须使用任何WorkerLauncher
类型的LaunchWorker
方法来初始化管道。这样我们可以重用Dispatcher
接口来启动多种类型的WorkerLaunchers
。
当使用MakeRequest(Request)
时,Dispatcher
接口公开了一个很好的方法,可以将新的Request
注入 workers 池。
最后,当所有 goroutine 必须完成时,用户必须调用 stop。我们必须在我们的应用程序中处理优雅的关闭,我们希望避免 Goroutine 泄漏。
我们有足够的接口,所以让我们从稍微不那么复杂的 dispatcher 开始:
type dispatcher struct {
inCh chan Request
}
我们的dispatcher
结构在其一个字段中存储Request
类型的通道。这将是任何管道中请求的单一入口点。我们说要落实三个办法:
func (d *dispatcher) LaunchWorker(id int, w WorkerLauncher) {
w.LaunchWorker(d.inCh)
}
func (d *dispatcher) Stop(){
close(d.inCh)
}
func (d *dispatcher) MakeRequest(r Request) {
d.inCh <- r
}
在本例中,Dispatcher
接口在启动一个 worker 之前不需要对自己做任何特殊的事情,因此Dispatcher
上的LaunchWorker
方法只是执行传入的WorkerLauncher,
的LaunchWorker
方法,该WorkerLauncher,
也有一个LaunchWorker
方法来启动自己。我们之前已经定义了WorkerLauncher
类型至少需要一个 ID 和一个传入请求的通道,所以这就是我们要传递的。
似乎没有必要在Dispatcher
接口中实现LaunchWorker
方法。在不同的场景中,将正在运行的工作 ID 保存在 dispatcher 中以控制哪些工作 ID 处于上升或下降状态可能会很有趣;这样做的目的是隐藏启动实现的细节。在这种情况下,Dispatcher
接口只是作为一个门面设计模式,向用户隐藏一些实现细节。
第二种方法是Stop
。它关闭了传入请求通道,引发了连锁反应。我们在管道示例中看到,当关闭传入通道时,Goroutine 中的每个 for range 循环都会中断,Goroutine 也会完成。在这种情况下,当关闭一个共享通道时,它将引发相同的反应,但在每个侦听 Goroutine 中,因此所有管道都将停止。酷吧?
请求实现非常简单;我们只是将参数中的请求传递给传入请求的通道。Goroutine 将永远在那里阻塞,直到通道的另一端检索到请求。永远如果发生了什么事,那似乎很多。我们可以引入超时,如下所示:
func (d *dispatcher) MakeRequest(r Request) {
select {
case d.inCh <- r:
case <-time.After(time.Second * 5):
return
}
}
如果您还记得前几章的内容,我们可以使用 select 来控制在通道上执行的操作。就像switch
案例一样,只需执行一个操作。在本例中,我们有两种不同的操作:发送和接收。
第一种情况是发送操作——尝试发送此操作,它将在那里阻塞,直到有人在通道的另一侧获取值。那不是很大的进步。第二种情况是接收操作;如果无法成功发送上层请求,则在 5 秒后触发,函数将返回。在这里返回一个错误是非常方便的,但是为了简单起见,我们将它保留为空
最后,为了方便起见,我们将在 dispatcher 中定义一个Dispatcher
创建者:
func NewDispatcher(b int) Dispatcher {
return &dispatcher{
inCh:make(chan Request, b),
}
}
通过使用此函数而不是手动创建调度器,我们可以简单地避免一些小错误,例如忘记初始化通道字段。如您所见,b
参数引用通道中的缓冲区大小。
因此,我们的调度已经完成,我们需要开发验收标准中描述的管道。首先,我们需要一个类型来实现WorkerLauncher
类型:
// worker.go file
type PreffixSuffixWorker struct {
id int
prefixS string
suffixS string
}
func (w *PreffixSuffixWorker) LaunchWorker(i int, in chan Request) {}
PreffixSuffixWorker
变量存储一个 ID、一个前缀字符串和另一个后缀字符串,以作为Request
类型的传入数据的后缀。因此,前缀和附加的值在这些字段中是静态的,我们将从那里获取它们。
稍后我们将实施LaunchWorker
方法,并从管道中的每个步骤开始。根据首次验收标准,传入字符串必须为大写。因此,大写方法将是我们管道中的第一步:
func (w *PreffixSuffixWorker) uppercase(in <-chan Request) <-chan Request {
out := make(chan Request)
go func() {
for msg := range in {
s, ok := msg.Data.(string)
if !ok {
msg.handler(nil)
continue
}
msg.Data = strings.ToUpper(s)
out <- msg
}
close(out)
}()
return out
}
好的与前一章一样,管道中的步骤接受传入数据通道并返回相同类型的通道。它的方法与我们在前一章中开发的示例非常相似。不过,这次我们没有使用包函数,大写是PreffixSuffixWorker
类型的一部分,传入的数据是struct
而不是int
。
msg
变量是Request
类型,它将以接口的形式具有处理函数和数据。Data
字段应该是字符串,所以我们在使用它之前键入 cast。当类型转换值时,我们将收到与请求的类型相同的值和一个true
或false
标志(由ok
变量表示)。如果ok
变量为false
,则无法进行转换,我们不会将该值抛出管道。我们在这里通过向处理程序发送一个nil
来停止这个Request
(这也会引发类型转换错误)。
一旦我们在s
变量中有了一个漂亮的字符串,我们就可以将其大写,并再次将其存储在Data
字段中,以便沿着管道发送到下一步。请注意,该值将再次作为接口发送,因此下一步需要再次强制转换它。这是使用这种方法的缺点。
第一步完成后,让我们继续第二步。根据现在的第二验收标准,必须添加预定义文本。此文本存储在suffixS
字段中:
func (w *PreffixSuffixWorker) append(in <-chan Request) <-chan Request {
out := make(chan Request)
go func() {
for msg := range in {
uppercaseString, ok := msg.Data.(string)
if !ok {
msg.handler(nil)
continue
}
msg.Data = fmt.Sprintf("%s%s", uppercaseString, w.suffixS)
out <- msg
}
close(out)
}()
return out
}
append
函数的结构与uppercase
函数相同。它接收并返回一个传入请求通道,并启动一个新的 Goroutine,该 Goroutine 在传入通道上迭代,直到关闭为止。如前所述,我们需要对传入值进行类型转换。
在管道中的这一步中,传入字符串是大写的(在执行类型断言之后)。要向它添加任何文本,我们只需要使用fmt.Sprintf()
函数,就像我们以前多次做的那样,它用提供的数据格式化一个新字符串。在本例中,我们将suffixS
字段的值作为第二个值传递,以将其附加到字符串的末尾。
只缺少管道中的最后一步,前缀操作:
func (w *PreffixSuffixWorker) prefix(in <-chan Request) {
go func() {
for msg := range in {
uppercasedStringWithSuffix, ok := msg.Data.(string)
if !ok {
msg.handler(nil)
continue
}
msg.handler(fmt.Sprintf("%s%s", w.prefixS, uppercasedStringWithSuffix))
}
}()
}
在这个函数中,什么引起了您的注意?是的,它现在不返回任何频道。我们可以用两种方式完成整个管道。我想您已经意识到我们使用了一个Future
处理函数来执行,最终结果在管道中。第二种方法是传递一个通道,将数据返回到其原点。在某些情况下,一个未来就足够了,而在另一些情况下,通过一个通道可以更方便地连接到另一条管道(例如)。
在任何情况下,您必须已经非常熟悉管道中步骤的结构。我们强制转换值,检查强制转换的结果,如果出现任何错误,则将 nil 发送给处理程序。但是,如果一切正常,最后要做的是再次格式化文本,将prefixS
字段放在文本的开头,通过调用请求的处理程序将结果字符串发送回源。
现在,我们的工作人员即将完成,我们可以实施LaunchWorker
方法:
func (w *PreffixSuffixWorker) LaunchWorker(in chan Request) {
w.prefix(w.append(w.uppercase(in)))
}
这是工人们的全部!我们只需将返回通道传递到管道中的下一步,就像我们在上一章中所做的那样。请记住,管道是从调用内部到外部执行的。那么,任何进入管道的数据的执行顺序是什么?
- 数据通过
uppercase
方法启动的 Goroutine 进入管道。 - 然后,它进入
append
推出的 Goroutine。 - 最后,in 进入在
prefix
方法中启动的 Goroutine,该方法不返回任何内容,而是在为传入字符串添加更多数据前缀后执行处理程序。
现在我们有了一条完整的管道和一个管道调度程序。调度程序将启动尽可能多的管道实例,以将传入的请求路由到任何可用的工作进程。
如果没有一个工作人员在 5 秒内接受请求,则请求将丢失。
让我们在一个小应用程序中使用这个库。
我们将启动我们定义的管道的三名工人。我们使用NewDispatcher
函数创建调度器和接收所有请求的通道。该通道有一个固定的缓冲区,在阻塞之前,该缓冲区最多可存储 100 条传入消息:
// workers_pipeline.go
func main() {
bufferSize := 100
var dispatcher Dispatcher = NewDispatcher(bufferSize)
然后,我们将在Dispatcher
界面中调用LaunchWorker
方法三次,启动 workers,其中WorkerLauncher
类型已填充:
workers := 3
for i := 0; i < workers; i++ {
var w WorkerLauncher = &PreffixSuffixWorker{
prefixS: fmt.Sprintf("WorkerID: %d -> ", i),
suffixS: " World",
id:i,
}
dispatcher.LaunchWorker(w)
}
每个WorkerLauncher
类型都是PreffixSuffixWorker
的一个实例。前缀将是一个小文本,显示工人 ID 和后缀文本world
。
此时,我们有三个 Worker 和三个 Goroutine,每个都同时运行并等待消息到达:
requests := 10
var wg sync.WaitGroup
wg.Add(requests)
我们将提出 10 个请求。我们还需要一个 WaitGroup 来正确同步应用程序,这样它就不会过早退出。在处理并发应用程序时,您会发现自己经常使用 WaitGroups。对于 10 个请求,我们需要等待 10 次对Done()
方法的调用,因此我们使用delta10 来调用Add()
方法。它被称为 delta,因为你也可以在五个请求中传递一个-5。在某些情况下,它可能很有用:
for i := 0; i < requests; i++ {
req := NewStringRequest("(Msg_id: %d) -> Hello", i, &wg)
dispatcher.MakeRequest(req)
}
dispatcher.Stop()
wg.Wait()
}
为了发出请求,我们将迭代一个for
循环。首先,我们使用在实现部分开头编写的函数NewStringRequest
创建一个Request
。在此值中,Data
字段将是我们将通过管道传递的文本,它将是追加和后缀操作的“中间”文本。在这种情况下,我们将发送消息编号和单词hello
。
一旦我们有了一个请求,我们就用它调用MakeRequest
方法。在完成所有请求之后,我们停止调度程序,如前所述,这将引发一个连锁反应,停止管道中的所有 goroutine。
最后,我们等待组,以便接收到对Done()
方法的所有调用,这表示所有操作都已完成。是时候尝试一下了:
go run *
WorkerID: 1 -> (MSG_ID: 0) -> HELLO World
WorkerID: 0 -> (MSG_ID: 3) -> HELLO World
WorkerID: 0 -> (MSG_ID: 4) -> HELLO World
WorkerID: 0 -> (MSG_ID: 5) -> HELLO World
WorkerID: 2 -> (MSG_ID: 2) -> HELLO World
WorkerID: 1 -> (MSG_ID: 1) -> HELLO World
WorkerID: 0 -> (MSG_ID: 6) -> HELLO World
WorkerID: 2 -> (MSG_ID: 9) -> HELLO World
WorkerID: 0 -> (MSG_ID: 7) -> HELLO World
WorkerID: 0 -> (MSG_ID: 8) -> HELLO World
让我们分析第一条信息:
- 这将是零,因此发送的消息是
(Msg_id: 0) -> Hello
。 - 然后,文本是大写的,所以现在我们有了
(MSG_ID: 0) -> HELLO
。 - 大写字母后,将完成带有文本
world
(注意文本开头的空格)的追加操作。这将为我们提供文本(MSG_ID: 0) -> HELLO World
。 - 最后,文本
WorkerID: 1
(在本例中,第一个工作人员完成了任务,但可能是其中的任何一个)被添加到步骤 3 的文本中,以向我们提供完整的返回消息WorkerID: 1 -> (MSG_ID: 0) -> HELLO World
。
并发应用程序很难测试,尤其是在进行网络操作时。这可能很困难,代码可能会为了测试它而发生很多变化。在任何情况下,不进行测试都是不合理的。在这种情况下,测试我们的小应用程序并不特别困难。创建测试并复制/粘贴main
功能的内容:
//workers_pipeline.go file
package main
import "testing"
func Test_Dispatcher(t *testing.T){
//pasted code from main function
bufferSize := 100
var dispatcher Dispatcher = NewDispatcher(bufferSize)
workers := 3
for i := 0; i < workers; i++
{
var w WorkerLauncher = &PreffixSuffixWorker{
prefixS: fmt.Sprintf("WorkerID: %d -> ", i),
suffixS: " World",
id: i,
}
dispatcher.LaunchWorker(w)
}
//Simulate Requests
requests := 10
var wg
sync.WaitGroup
wg.Add(requests)
}
现在我们必须重写我们的处理程序来测试返回的内容是否是我们期望的内容。转到for
循环,修改我们作为处理程序在每个Request
上传递的函数:
for i := 0; i < requests; i++ {
req := Request{
Data: fmt.Sprintf("(Msg_id: %d) -> Hello", i),
handler: func(i interface{})
{
s, ok := i.(string)
defer wg.Done()
if !ok
{
t.Fail()
}
ok, err := regexp.Match(
`WorkerID\: \d* -\> \(MSG_ID: \d*\) -> [A-Z]*\sWorld`,
[]byte(s))
if !ok || err != nil {
t.Fail()
}
},
}
dispatcher.MakeRequest(req)
}
我们将使用正则表达式来测试业务。如果您不熟悉正则表达式,那么它们是一个非常强大的功能,可以帮助您匹配字符串中的内容。如果您还记得我们在练习中使用strings
软件包时的情景。Contains
是在字符串中查找文本的函数。我们也可以用正则表达式来实现。
问题是正则表达式非常昂贵,并且消耗大量资源。
我们正在使用regexp
包的Match
功能来提供一个匹配的模板。我们的模板是WorkerID\: \d* -> \(MSG_ID: \d\) -> [A-Z]*\sWorld
(不带引号)。具体来说,它描述了以下内容:
- 包含内容
WorkerID: \d* -> (MSG_ID: \d*", here "\d*
的字符串表示任何数字写入零次或多次,因此它将匹配WorkerID: 10 -> (MSG_ID: 1"
和"WorkerID: 1 -> (MSG_ID: 10
。 "\) -> [A-Z]*\sWorld"
(括号必须使用反斜杠转义)。“*
”表示任何大写字符写入零次或多次,因此"\s"
是一个空格,必须以文本World
结尾,因此) -> HELLO World"
将匹配,但) -> Hello World"
不会匹配,因为"Hello
必须全部为大写。
运行此测试将提供以下输出:
go test -v .
=== RUN Test_Dispatcher
--- PASS: Test_Dispatcher (0.00s)
PASS
ok
不错,但我们并没有测试代码是并发执行的,所以这更像是一个业务测试而不是单元测试。并发测试将迫使我们以完全不同的方式编写代码,以检查它是否正在创建适当数量的 goroutine,以及管道是否遵循预期的工作流。这并不坏,但它相当复杂,超出了本书的上下文。
有了 workers 池,我们就有了第一个可以在现实生产系统中使用的复杂并发应用程序。它也有改进的余地,但它是构建并发有界应用程序的一种非常好的设计模式。
关键是,我们始终能够控制正在启动的 goroutine 的数量。虽然在一个应用程序中启动数千个程序以实现更高的并行性很容易,但我们必须非常小心,它们没有可以将它们挂在无限循环中的代码。
有了 workers 池,我们现在可以在许多并行任务中分割一个简单的操作。想想看;这可以通过一个对fmt.Printf
的简单调用获得相同的结果,但我们已经用它完成了一个管道;然后,我们启动了该管道的几个实例,最后在所有这些管道之间分配工作负载。
在本节中,我们将实现我们之前在行为模式上展示的观察者设计模式,但具有并发结构和线程安全性。
如果您还记得前面的解释,Observer 模式维护了希望收到特定事件通知的观察者或订阅者的列表。在这种情况下,每个订阅服务器将在不同的 Goroutine 和发布服务器中运行。我们在建造这种结构时会遇到新的问题:
- 现在,必须序列化对订阅服务器列表的访问。如果我们用一个 Goroutine 读取列表,我们不能从中删除订阅者,否则我们将进行竞争。
- 当一个订阅服务器被删除时,订阅服务器的 Goroutine 也必须被关闭,否则它将永远重复,我们将遇到 Goroutine 泄漏。
- 停止发布服务器时,所有订阅服务器也必须停止其 goroutine。
此发布/订阅服务器的目标与我们在 Observer 模式中编写的目标相同。这里的不同之处在于我们开发它的方式。其思想是创建一个并发结构以实现相同的功能,如下所示:
- 提供一个事件驱动的体系结构,其中一个事件可以触发一个或多个操作
- 将执行的操作与触发它们的事件分离
- 提供触发同一操作的多个源事件
其思想是将发送者与接收者分离,向发送者隐藏将处理其事件的接收者的身份,并向能够与之通信的发送者数量隐藏接收者。
特别是,如果我在某个应用程序中开发一个点击按钮,它可以做一些事情(比如让我们在某处登录)。几周后,我们可能会决定让它也显示一个弹出窗口。如果每次我们想给这个按钮添加一些功能,我们必须更改它处理点击操作的代码,那么这个功能将变得巨大,并且不能很好地移植到其他项目中。如果我们对每个操作使用一个发布者和一个观察者,那么单击功能只需要使用发布者发布一个事件,并且每次我们想要改进功能时,我们只需向该事件写入订阅者。这在具有用户界面的应用程序中尤其重要,因为在一个 UI 操作中要做的许多事情可能会降低界面的响应速度,从而完全破坏用户体验。
通过使用并发结构来开发观察者模式,如果定义了并发结构并且设备允许我们执行并行任务,那么 UI 就无法感觉到后台正在执行的所有任务。
我们将开发一个通知程序类似于我们在第 7 章中开发的行为模式——访客、状态、中介和观察者设计模式。这是为了关注结构的并发性,而不是详述已经解释过的太多内容。我们已经开发了一个观察者,所以我们对这个概念很熟悉。
这个特定的通知程序将通过传递interface{}
值来工作,如 workers 池示例中所示。通过这种方式,我们可以在对接收器进行强制转换时引入一些开销,从而将其用于多种类型。
我们现在将使用两个接口。首先,一个Subscriber
接口:
type Subscriber interface {
Notify(interface{}) error
Close()
}
与前面的示例一样,它必须在新事件的Subscriber
接口中有一个Notify
方法。这是接受interface{}
值并返回错误的Notify
方法。然而,Close()
方法是新的,它必须触发任何需要的操作来停止 Goroutine,订阅者正在侦听新事件。
第二个也是最后一个接口是Publisher
接口:
type Publisher interface {
start()
AddSubscriberCh() chan<- Subscriber
RemoveSubscriberCh() chan<- Subscriber
PublishingCh() chan<- interface{}
Stop()
}
Publisher
接口具有与我们已知的发布者相同的操作,但用于频道。AddSubscriberCh
和RemoveSubscriberCh
方法接受Subscriber
接口(满足Subscriber
接口的任何类型)。它必须有发布消息的方法和停止消息的Stop
方法(发布者和订阅者 Goroutines)
本例与第 7 章中的行为模式【访客、状态、调解人和观察者设计模式之间的要求不得改变。两个示例中的目标相同,因此要求也必须相同。在这种情况下,我们的要求是技术性的,因此我们实际上需要添加更多的验收标准:
- 我们必须有一个具有
PublishingCh
方法的发布者,该方法返回一个通道来发送消息,并在每个订阅的观察者上触发Notify
方法。 - 我们必须有一个向发布服务器添加新订阅服务器的方法。
- 我们必须有一个从发布服务器中删除新订阅服务器的方法。
- 我们必须有一个方法来阻止订户。
- 我们必须有一种方法来停止
Publisher
接口,该接口也将停止所有订阅者。 - 所有 Goroutine 间通信必须同步,以便在等待响应时不会锁定 Goroutine。在这种情况下,指定的超时时间过后将返回错误。
嗯,这些标准似乎相当令人畏惧。我们省略了一些会增加更复杂度的要求,例如删除无响应订户或检查以监视发布服务器 Goroutine 是否始终处于打开状态。
我们前面已经提到,测试并发应用程序可能很困难。有了正确的机制,它仍然可以完成,所以让我们看看我们可以在没有大麻烦的情况下测试多少。
从订阅者开始,第一个订阅者必须将来自发布者的传入消息打印到io.Writer
接口,订阅者似乎具有更为封装的功能。我们已经提到订户有两种方式的接口,Notify(interface{}) error
和Close()
方式:
// writer_sub.go file
package main
import "errors"
type writerSubscriber struct {
id int
Writer io.Writer
}
func (s *writerSubscriber) Notify(msg interface{}) error {
return erorrs.NeW("Not implemented yet")
}
func (s *writerSubscriber) Close() {}
好啊这将是我们的writer_sub.go
文件。创建相应的测试文件,称为writer_sub_test.go
文件:
package main
func TestStdoutPrinter(t *testing.T) {
现在,我们遇到的第一个问题是功能打印到stdout
,因此没有要检查的返回值。我们可以通过三种方式解决这个问题:
- 捕捉
stdout
方法。 - 注入
io.Writer
接口进行打印。这是首选的解决方案,因为它使代码更易于管理。 - 将
stdout
方法重定向到其他文件。
我们将采用第二种方法。重定向也是一种可能性。os.Stdout
是一个指向os.File
类型的指针,因此它涉及到用我们控制的文件替换此文件,并从中读取:
func TestWriter(t *testing.T) {
sub := NewWriterSubscriber(0, nil)
NewWriterSubscriber
订户尚未定义。它必须有助于创建这个特定的订阅服务器,返回满足Subscriber
接口的类型,因此让我们快速在writer_sub.go
文件中声明它:
func NewWriterSubscriber(id int, out io.Writer) Subscriber {
return &writerSubscriber{}
}
理想情况下,它必须接受一个 ID 和一个io.Writer
接口作为其写入的目标。在这种情况下,我们的测试需要一个自定义的io.Writer
接口,因此我们将在writer_sub_test.go
文件上为它创建一个mockWriter
:
type mockWriter struct {
testingFunc func(string)
}
func (m *mockWriter) Write(p []byte) (n int, err error) {
m.testingFunc(string(p))
return len(p), nil
}
mockWriter
结构将接受testingFunc
作为其字段之一。此testingFunc
字段接受表示写入mockWriter
结构的字节的字符串。为了实现一个io.Writer
接口,我们需要定义一个Write([]byte) (int, error)
方法。在我们的定义中,我们将p
的内容作为字符串传递(记住,我们总是需要在每个Write
方法上返回读取的字节和错误,或者不返回)。该方法将testingFunc
的定义委托给测试范围。
我们将在Subcriber
接口上调用Notify
方法,该方法必须像mockWriter
结构一样写入io.Writer
接口。因此,在调用Notify
方法之前,我们将定义mockWriter
结构的testingFunc
:
// writer_sub_test.go file
func TestPublisher(t *testing.T) {
msg := "Hello"
var wg sync.WaitGroup
wg.Add(1)
stdoutPrinter := sub.(*writerSubscriber)
stdoutPrinter.Writer = &mockWriter{
testingFunc: func(res string) {
if !strings.Contains(res, msg) {
t.Fatal(fmt.Errorf("Incorrect string: %s", res))
}
wg.Done()
},
}
我们将发送Hello
消息。这也意味着无论Subscriber
接口做什么,它最终都必须在提供的io.Writer
接口上打印Hello
消息。
因此,如果我们最终在测试函数中收到一个字符串,我们需要与Subscriber
接口同步,以避免测试中出现争用条件。这就是为什么我们使用如此多的WaitGroup
。这是一个非常方便和易于使用的类型来处理这个场景。一个Notify
方法调用需要等待一个Done()
方法调用,所以我们调用Add(1)
方法(一个单元)。
理想情况下,NewWriterSubscriber
函数必须返回一个接口,因此我们需要将其类型断言为测试期间使用的类型,在本例中为stdoutPrinter
方法。我故意省略了错误检查,只是为了让事情更简单。一旦我们有了一个writerSubscriber
类型,我们就可以访问它的Write
字段,用mockWriter
结构替换它。我们可以直接在NewWriterSubscriber
函数上传递io.Writer
接口,但我们不会涉及传递 nil 对象并将os.Stdout
实例设置为默认值的场景。
因此,测试函数最终将接收一个字符串,其中包含订阅者编写的内容。我们只需要检查接收到的字符串,Subscriber
接口将接收到的字符串,是否在某个点打印出单词Hello
,没有比strings.Contains
函数更好的了。所有内容都在测试函数的范围内定义,因此我们可以使用t
对象的值来表示测试失败。
完成检查后,我们必须调用Done()
方法来表示我们已经测试了预期结果:
err := sub.Notify(msg)
if err != nil {
t.Fatal(err)
}
wg.Wait()
sub.Close()
}
我们实际上必须调用Notify
和Wait
方法来调用Done
方法,以检查一切是否正确。
您是否意识到我们对测试行为的定义或多或少是相反的?这在并发应用程序中非常常见。有时可能会令人困惑,因为如果我们不能线性地跟踪调用,就很难知道函数可以做什么,但您很快就会习惯它。与“它做这个,然后这个,然后那个”的想法不同,它更像是“在执行那个时会调用这个”。这也是因为并发应用程序中的执行顺序直到某一点都是未知的,除非我们使用同步原语(如 waitgroup 和 channels)在某些时刻暂停执行。
现在让我们执行此类型的测试:
go test -cover -v -run=TestWriter .
=== RUN TestWriter
--- FAIL: TestWriter (0.00s)
writer_sub_test.go:40: Not implemented yet
FAIL
coverage: 6.7% of statements
exit status 1
FAIL
它很快退出,但失败了。实际上,对Done()
方法的调用尚未执行,因此最好将测试的最后一部分改为:
err := sub.Notify(msg)
if err != nil {
wg.Done()
t.Error(err)
}
wg.Wait()
sub.Close()
}
现在,它不会停止执行,因为我们调用的是Error
函数而不是Fatal
函数,但是我们调用Done()
方法,在调用Wait()
方法之后,测试在我们希望它结束的地方结束。您可以尝试再次运行测试,但输出将是相同的。
我们已经看到了一个Publisher
接口和将满足的类型,即publisher
类型。我们唯一可以确定的是,它需要某种方式来存储订户,因此它至少会有一个Subscribers
片段:
// publisher.go type
type publisher struct {
subscribers []Subscriber
}
为了测试publisher
类型,我们还需要对Subscriber
接口进行模拟:
// publisher_test.go
type mockSubscriber struct {
notifyTestingFunc func(msg interface{})
closeTestingFunc func()
}
func (m *mockSubscriber) Close() {
m.closeTestingFunc()
}
func (m *mockSubscriber) Notify(msg interface{}) error {
m.notifyTestingFunc(msg)
return nil
}
mockSubscriber
类型必须实现Subscriber
接口,所以必须有Close()
和Notify(interface{}) error
方法。我们可以嵌入一个实现它的现有类型,比如writerSubscriber
,并覆盖我们感兴趣的方法,但我们需要同时定义这两个方法,所以我们不会嵌入任何东西。
因此,在这种情况下,我们需要重写Notify
和Close
方法来调用存储在mockSubscriber
类型字段上的测试函数:
func TestPublisher(t *testing.T) {
msg := "Hello"
p := NewPublisher()
首先,我们将直接通过通道发送消息,这可能会导致潜在的不必要的死锁,因此首先要定义一个紧急处理程序,用于诸如发送关闭通道或没有 Goroutines 侦听通道等情况。我们将向订户发送的消息是Hello
。因此,使用AddSubscriberCh
方法返回的通道接收的每个订户都必须接收此消息。我们还将使用新的函数创建发布者,称为NewPublisher
。现在将publisher.go
文件更改为写入:
// publisher.go file
func NewPublisher() Publisher {
return &publisher{}
}
现在我们将定义mockSubscriber
以将其添加到已知订阅者的发布者列表中。回到publisher_test.go
文件:
var wg sync.WaitGroup
sub := &mockSubscriber{
notifyTestingFunc: func(msg interface{}) {
defer wg.Done()
s, ok := msg.(string)
if !ok {
t.Fatal(errors.New("Could not assert result"))
}
if s != msg {
t.Fail()
}
},
closeTestingFunc: func() {
wg.Done()
},
}
像往常一样,我们从一个等待组开始。首先,在订阅者中测试函数会在Done()
方法执行结束时延迟对其的调用。然后它需要输入 castmsg
变量,因为它是作为一个接口来的。记住,通过引入类型断言的开销,我们可以将Publisher
接口用于许多类型。这是在s, ok := msg.(string)
线上完成的。
一旦我们将msg
类型转换为字符串s
,我们只需要检查订阅服务器中接收到的值是否与我们发送的值相同,否则测试失败:
p.AddSubscriberCh() <- sub
wg.Add(1)
p.PublishingCh() <- msg
wg.Wait()
我们使用AddSubscriberCh
方法添加mockSubscriber
类型。我们在准备就绪后发布消息,在WaitGroup
中添加一条消息,并在WaitGroup
设置为等待之前发布消息,以便测试不会继续,直到mockSubscriber
类型调用Done()
方法。
另外,我们需要检查调用AddSubscriberCh
方法后Subscriber
接口的数量是否增加,所以我们需要在测试中得到 publisher 的具体实例:
pubCon := p.(*publisher)
if len(pubCon.subscribers) != 1 {
t.Error("Unexpected number of subscribers")
}
类型断言是我们今天的朋友!一旦我们有了具体的类型,我们就可以访问Publisher
接口的订阅服务器的底层部分。调用AddSubscriberCh
方法一次,用户数必须为 1,否则测试失败。下一步是检查相反的情况——当我们删除Subscriber
接口时,它必须从以下列表中获取:
wg.Add(1)
p.RemoveSubscriberCh() <- sub
wg.Wait()
//Number of subscribers is restored to zero
if len(pubCon.subscribers) != 0 {
t.Error("Expected no subscribers")
}
p.Stop()
}
我们测试的最后一步是停止发布服务器,这样就不会再发送消息,所有 goroutine 都会停止。
测试已经完成,但是我们不能运行测试,直到publisher
类型实现了所有的方法;这必须是最终结果:
type publisher struct {
subscribers []Subscriber
addSubCh chan Subscriber
removeSubCh chan Subscriber
in chan interface{}
stop chan struct{}
}
func (p *publisher) AddSubscriberCh() chan<- Subscriber {
return nil
}
func (p *publisher) RemoveSubscriberCh() chan<- Subscriber {
return nil
}
func (p *publisher) PublishingCh() chan<- interface{} {
return nil
}
func (p *publisher) Stop(){}
使用此空实现,在运行测试时不会发生任何好事:
go test -cover -v -run=TestPublisher .
atal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
testing.(*T).Run(0xc0420780c0, 0x5244c6, 0xd, 0x5335a0, 0xc042037d20)
/usr/local/go/src/testing/testing.go:647 +0x31d
testing.RunTests.func1(0xc0420780c0)
/usr/local/go/src/testing/testing.go:793 +0x74
testing.tRunner(0xc0420780c0, 0xc042037e10)
/usr/local/go/src/testing/testing.go:610 +0x88
testing.RunTests(0x5335b8, 0x5ada40, 0x2, 0x2, 0x40d7e9)
/usr/local/go/src/testing/testing.go:799 +0x2fc
testing.(*M).Run(0xc042037ed8, 0xc04200a4f0)
/usr/local/go/src/testing/testing.go:743 +0x8c
main.main()
go-design-patterns/concurrency_3/pubsub/_test/_testmain.go:56 +0xcd
goroutine 5 [chan send (nil chan)]:
go-design-patterns/concurrency_3/pubsub.TestPublisher(0xc042078180)
go-design-patterns/concurrency_3/pubsub/publisher_test.go:55 +0x372
testing.tRunner(0xc042078180, 0x5335a0)
/usr/local/go/src/testing/testing.go:610 +0x88
created by testing.(*T).Run
/usr/local/go/src/testing/testing.go:646 +0x2f3
exit status 2
FAIL go-design-patterns/concurrency_3/pubsub 1.587s
是的,它失败了,但它根本不是可控的失败。这样做是为了表明在围棋中需要注意的几件事。首先,本测试中产生的错误是一个致命错误,通常指向代码中的错误。这一点很重要,因为虽然恐慌错误可以恢复,但致命错误不能恢复。
在本例中,错误告诉我们问题:goroutine 5 [chan send (nil chan)]
,一个 nil 通道,因此它实际上是我们代码中的一个 bug。我们如何解决这个问题?嗯,这也很有趣。
我们有一个nil
通道这一事实是由我们为编译单元测试而编写的代码造成的,但一旦编写了适当的代码,就不会出现这个特定错误(因为在这种情况下,我们永远不会返回 nil 通道)。我们可以返回一个从未使用过的通道,因为它会导致死锁的致命错误,这也不会有任何进展。
解决这个问题的惯用方法是返回一个通道和一个错误,这样您就可以得到一个错误包,该错误包的类型实现了返回特定错误的Error
接口,例如NoGoroutinesListening
或ChannelNotCreated
。我们已经看到了很多这样的实现,所以我们将把它们作为练习留给读者,我们将继续关注本章的并发性。
这并不奇怪,因此我们可以进入实施阶段。
回想一下,writerSubscriber
必须接收它将在满足io.Writer
接口的类型上写入的消息。
那么,我们从哪里开始呢?每个订户都会运行自己的 Goroutine,我们已经看到,与 Goroutine 通信的最佳方法是通道。因此,我们需要一个字段,其通道为Subscriber
类型。我们可以使用与管道中相同的方法以NewWriterSubscriber
功能和writerSubscriber
类型结束:
type writerSubscriber struct {
in chan interface{}
id int
Writer io.Writer
}
func NewWriterSubscriber(id int, out io.Writer) Subscriber {
if out == nil {
out = os.Stdout
}
s := &writerSubscriber{
id: id,
in: make(chan interface{}),
Writer: out,
}
go func(){
for msg := range s.in {
fmt.Fprintf(s.Writer, "(W%d): %v\n", s.id, msg)
}
}()
return s
}
在第一步中,如果没有指定 writer(out
参数为 nil),则默认的io.Writer
接口为stdout
。然后,我们创建一个指向writerSubscriber
类型的新指针,该指针具有在第一个参数中传递的 ID、out(os.Stdout
的值,或者参数中出现的任何值(如果不是 nil),以及一个调用的通道,以保持与前面示例中相同的命名。
然后我们推出一个新的 Goroutine;这就是我们提到的启动机制。与管道中一样,每次收到新消息时,订阅者都会迭代in
通道,并将其内容格式化为字符串,该字符串还包含当前订阅者的 ID。
如前所述,如果in
通道关闭,for range
循环将停止,特定的 Goroutine 将完成,因此在Close
方法中,我们需要做的唯一一件事就是实际关闭in
通道:
func (s *writerSubscriber) Close() {
close(s.in)
}
好的,只剩下Notify
方法了;Notify
方法是在通信时管理特定行为的方便方法,我们将使用在许多调用中常见的模式:
func (s *writerSubscriber) Notify(msg interface{}) (err error) {
defer func(){
if rec := recover(); rec != nil {
err = fmt.Errorf("%#v", rec)
}
}()
select {
case s.in <- msg:
case <-time.After(time.Second):
err = fmt.Errorf("Timeout\n")
}
return
}
当与通道通信时,我们通常必须控制两种行为:一种是等待时间,另一种是通道关闭时。延迟函数实际上适用于函数中可能发生的任何恐慌性错误。如果 Goroutine 恐慌,它仍然会使用recover()
方法执行延迟函数。recover()
方法返回错误所在的接口,因此在本例中,我们将返回变量 error 设置为recover
返回的格式化值(这是一个接口)。当格式化为字符串时,"%#v"
参数为我们提供了有关任何类型的大部分信息。返回的错误将很难看,但它将包含我们可以提取的有关错误的大部分信息。例如,对于封闭通道,它将返回“在封闭通道上发送”。嗯,这似乎很清楚。
第二条规则是关于等待时间。当我们通过一个通道发送一个值时,我们将被阻止,直到另一个 Goroutine 从中获取该值为止(对于填充的缓冲通道也会发生同样的情况)。我们不想永远被阻塞,所以我们使用 select 处理程序将超时时间设置为 1 秒。简而言之,使用 select 时,我们的意思是:要么在 1 秒内获取该值,要么我将放弃该值并返回一个错误。
我们有Close
、Notify
和NewWriterSubscriber
方法,所以我们可以再次尝试我们的测试:
go test -run=TestWriter -v .
=== RUN TestWriter
--- PASS: TestWriter (0.00s)
PASS
ok
现在好多了。Writer
已经使用了我们在测试中编写的模拟编写器,并将传递给 Notify 方法的值写入其中。同时,close 可能已经有效地关闭了通道,因为Notify
方法在调用Close
方法后返回了一个错误。需要提及的一点是,如果不与通道进行交互,我们无法检查通道是否关闭;这就是为什么我们不得不推迟执行一个闭包,该闭包将检查Notify
方法中recover()
函数的内容。
好的,发布者还需要一个启动机制,但是要处理的主要问题是访问订户列表的竞争条件。我们可以通过sync
包中的互斥对象解决这个问题,但是我们已经看到了如何使用它,所以我们将使用通道。
在使用通道时,我们需要为每个可能被视为危险的操作提供一个通道——添加一个订阅者,删除一个订阅者,检索订阅者列表以Notify
方法获取消息,以及一个停止所有订阅者的通道。我们还需要一个接收消息的通道:
type publisher struct {
subscribers []Subscriber
addSubCh chan Subscriber
removeSubCh chan Subscriber
in chan interface{}
stop chan struct{}
}
姓名是自描述性的,但简而言之,订阅者维护订阅者列表;这是需要多路复用访问的片。addSubCh
实例是新增用户时要与之通信的通道;这就是为什么它是一个用户频道。同样的解释适用于removeSubCh
频道,但该频道用于移除订户。in
频道将处理必须广播给所有订户的传入消息。最后,当我们想要杀死所有 goroutine 时,必须调用 stop 通道。
好的,让我们从AddSubscriberCh
、RemoveSubscriber
和PublishingCh
方法开始,它们必须返回添加和删除订户的通道以及向所有订户发送消息的通道:
func (p *publisher) AddSubscriber() {
return p.addSubCh
}
func (p *publisher) RemoveSubscriberCh() {
return p.removeSubCh
}
func (p *publisher) PublishMessage(){
return p.in
}
Stop()
通过关闭stop
通道来实现其功能。这将有效地将信号传播到每个收听 Goroutine:
func (p *publisher) Stop(){
close(p.stop)
}
Stop
方法,即停止发布者和订阅者的功能,也会推送到其各自的通道,称为停止。
您可能想知道为什么我们不简单地保留频道,以便用户直接推送到该频道,而不是使用代理功能。这个想法是,在应用程序中集成库的用户不必处理与库相关的并发结构的复杂性,因此他们可以专注于自己的业务,同时尽可能提高性能。
到目前为止,我们已经将数据转发到发布服务器上的频道,但实际上我们还没有处理任何这些数据。将要启动不同 Goroutine 的启动器机制将处理所有这些问题。
我们将创建一个启动方法,通过使用go
关键字来执行,而不是将整个函数嵌入NewPublisher
函数中:
func (p *publisher) start() {
for {
select {
case msg := <-p.in:
for _, ch := range p.subscribers {
sub.Notify(msg)
}
Launch
是一个私有方法,我们尚未对其进行测试。记住,私有方法通常是从公共方法(我们已经测试过的方法)调用的。通常,如果私有方法不是从公共方法调用的,则根本无法调用它!
我们注意到这个方法的第一点是,它是一个无限 for 循环,将在多个通道之间重复一个 select 操作,但每次只能执行其中一个。这些操作中的第一个是接收要发布给订阅者的新消息的操作。case msg := <- p.in:
代码处理此传入操作。
在本例中,我们迭代所有订阅者并执行他们的Notify
方法。您可能想知道为什么我们不在前面添加go
关键字,以便Notify
方法作为不同的 Goroutine 执行,因此迭代速度更快。这是因为我们没有分离接收消息和关闭消息的操作。因此,如果我们在一个新的 Goroutine 中启动订阅者,并且在Notify
方法中处理消息时,订阅者是关闭的,那么我们将有一个竞争条件,消息将尝试在Notify
方法中发送到一个关闭的通道。事实上,我们在开发Notify
方法时正在考虑这种情况,但是,如果我们每次在新的 Goroutine 中调用Notify
方法,我们仍然无法控制启动的 Goroutine 的数量。为简单起见,我们只调用Notify
方法,但控制Notify
方法执行中等待返回的 goroutine 的数量是一个很好的练习。通过缓冲每个用户的in
通道,我们也可以得到一个很好的解决方案:
case sub := <-p.addSubCh:
p.subscribers = append(p.subscribers, sub)
下一个操作是当值到达通道以添加订户时要做什么。在本例中,它很简单:我们更新它,将新值附加到它。执行此案例时,在此选择中不能执行其他调用:
case sub := <-p.removeSubCh:
for i, candidate := range p.subscribers {
if candidate == sub {
p.subscribers = append(p.subscribers[:i], p.subscribers[i+1:]...)
candidate.Close()
break
}
}
当一个值到达 remove 通道时,操作会稍微复杂一些,因为我们必须在片中搜索订户。我们使用了一种*O(N)*方法,从一开始就迭代直到找到它,但是搜索算法可以大大改进。一旦我们找到了相应的Subscriber
接口,我们就将其从订阅服务器片中删除并停止它。需要提到的一点是,在测试中,我们直接访问订阅者片的长度,而无需解复用操作。这显然是一种竞争条件,但通常在运行竞争检测器时不会反映出来。
解决方案是开发一种方法,只需多路复用调用即可获得切片的长度,但它不属于公共接口。同样,为了简单起见,我们将其保留如下,否则此示例可能会变得太复杂而无法处理:
case <-p.stop:
for _, sub := range p.subscribers {
sub.Close()
}
close(p.addSubCh)
close(p.in)
close(p.removeSubCh)
return
}
}
}
解复用的最后一个操作是stop
操作,它必须停止发布服务器和订阅服务器中的所有 goroutine。然后我们必须遍历存储在 subscribers 字段中的每个订阅者来执行他们的Close()
方法,因此他们的 goroutine 也被关闭。最后,如果我们返回这个 Goroutine,它也将结束。
好了,是时候执行所有测试了,看看情况如何:
go test -race .
ok
还不错。所有测试都已成功通过,我们的观察者模式已准备就绪。虽然这个例子仍然可以改进,但它是一个很好的例子,说明了我们必须如何使用 Go 中的通道处理观察者模式。作为练习,我们鼓励您尝试使用互斥体而不是通道来控制访问的相同示例。它更容易一点,也会让您了解如何使用互斥体。
此示例演示了如何通过实现 Observer 模式,利用多核 CPU 构建并发消息发布器。虽然示例很长,但我们尝试在 Go 中开发并发应用程序时展示一种通用模式。
我们已经看到很少有方法开发可以并行运行的并发结构。我们试图展示几种解决同一问题的方法,一种没有并发原语,另一种有并发原语。我们已经看到了使用并发结构编写的发布/订阅者示例与经典的发布/订阅者示例相比有多么不同。
我们还了解了如何使用管道构建并发操作,并通过使用工作池(一种非常常见的 Go 模式)将其并行化,以最大限度地提高并行性。
这两个例子都很简单,可以理解,同时尽可能深入了解围棋语言的本质,而不是问题本身。