Go 并发编程示例解析
1. 并发编程模式概述
Go 语言使用较少的语法(如
<-
、
chan
、
go
、
select
)就能以丰富多样的方式实现并发。常见的并发编程模式有三种:管道模式、多个独立并发任务模式(有或无同步结果)以及多个相互依赖的并发任务模式。下面将通过具体示例来详细介绍这些模式。
2. 过滤器(Filter)示例
2.1 管道概念与 Unix 管道类比
对于有 Unix 背景的人来说,Go 的通道类似于 Unix 管道,不同的是通道是双向的,而管道是单向的。Unix 管道可用于创建流水线,即一个程序的输出作为另一个程序的输入,以此类推。例如,使用
find $GOROOT/src -name "*.go" | grep -v test.go
命令可以获取 Go 源代码树中除测试文件外的所有 Go 文件列表。而且这种方式易于扩展,如添加
| xargs wc -l
可统计每个文件的行数,再添加
| sort -n
可按行数对文件进行排序。除了使用标准库的
io.Pipe()
函数创建 Unix 风格的管道外,还可以使用通道创建管道,下面将详细介绍。
2.2 过滤器程序实现
过滤器示例程序(
filter/filter.go
)接受一些命令行参数(如指定最小和最大文件大小以及可接受的文件后缀)和文件列表,并输出列表中符合命令行指定标准的文件。以下是该程序
main()
函数的主体:
minSize, maxSize, suffixes, files := handleCommandLine()
sink(filterSize(minSize, maxSize, filterSuffixes(suffixes, source(files))))
handleCommandLine()
函数使用标准库的
flag
包处理命令行参数。管道从最内层的函数调用(
source(files)
)到最外层的(
sink()
)依次执行。为了更易于理解,将管道布局如下:
channel1 := source(files)
channel2 := filterSuffixes(suffixes, channel1)
channel3 := filterSize(minSize, maxSize, channel2)
sink(channel3)
-
source()函数 :
func source(files []string) <-chan string {
out := make(chan string, 1000)
go func() {
for _, filename := range files {
out <- filename
}
close(out)
}()
return out
}
此函数创建一个用于传递文件名的通道,使用缓冲通道以提高吞吐量。创建一个 goroutine 遍历文件并将每个文件名发送到通道,所有文件发送完毕后关闭通道。最后将双向通道作为单向接收通道返回,以更明确表达意图。
-
filterSuffixes()函数 :
func filterSuffixes(suffixes []string, in <-chan string) <-chan string {
out := make(chan string, cap(in))
go func() {
for filename := range in {
if len(suffixes) == 0 {
out <- filename
continue
}
ext := strings.ToLower(filepath.Ext(filename))
for _, suffix := range suffixes {
if ext == suffix {
out <- filename
break
}
}
}
close(out)
}()
return out
}
该函数是两个过滤器函数之一,首先创建一个与输入通道缓冲区大小相同的输出通道。创建一个 goroutine 遍历输入通道中的文件名,如果未指定后缀,则将所有文件名发送到输出通道;如果指定了后缀,则仅将后缀匹配的文件名发送到输出通道。处理完成后关闭输出通道,并将其作为单向接收通道返回。
-
sink()函数 :
func sink(in <-chan string) {
for filename := range in {
fmt.Println(filename)
}
}
该函数在主 goroutine 中操作,遍历最后一个通道中的文件名并输出。其
range
语句会一直阻塞,直到通道关闭,确保主 goroutine 在其他 goroutine 处理完成后才终止。
2.3 过滤器程序并发结构
在过滤器程序中,
sink()
函数在主 goroutine 中执行,每个管道函数(如
source()
、
filterSuffixes()
和
filterSize()
)在各自的 goroutine 中执行。这意味着每个管道函数调用会立即返回,执行迅速到达
sink()
函数。此时所有 goroutine 并发执行,直到所有文件处理完毕。
以下是该程序的并发结构示意图:
graph LR
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
classDef channel fill:#FFF6CC,stroke:#FFBC52,stroke-width:2px;
A(Main goroutine: sink()):::process --> C(Channel #3):::channel
B1(Goroutine #1: source()):::process --> C1(Channel #1):::channel
B2(Goroutine #2: filterSuffixes()):::process --> C2(Channel #2):::channel
B3(Goroutine #3: filterSize()):::process --> C
C1 --> B2
C2 --> B3
C --> A
3. 并发 grep(Concurrent Grep)示例
3.1 并发 grep 模式概述
常见的并发编程模式之一是有多个独立任务,每个任务可以独立完成。例如,Go 标准库的
net/http
包中的 HTTP 服务器就遵循这种模式,每个请求在独立的 goroutine 中并发处理,且 goroutine 之间无通信。下面将以
cgrep
“并发 grep” 程序的变体为例,介绍如何实现这种模式。
3.2 cgrep1 程序实现
cgrep1
程序(
cgrep1/cgrep.go
)使用三个通道,其中两个用于发送和接收结构体。
type Job struct {
filename string
results chan<- Result
}
type Result struct {
filename string
lino int
line string
}
Job
结构体用于指定每个任务,包含要处理的文件名和结果发送的通道;
Result
结构体封装每个结果,包含文件名、行号和匹配的行。
以下是
main()
函数:
func main() {
runtime.GOMAXPROCS(runtime.NumCPU()) // Use all the machine's cores
if len(os.Args) < 3 || os.Args[1] == "-h" || os.Args[1] == "--help" {
fmt.Printf("usage: %s <regexp> <files>\n",
filepath.Base(os.Args[0]))
os.Exit(1)
}
if lineRx, err := regexp.Compile(os.Args[1]); err != nil {
log.Fatalf("invalid regexp: %s\n", err)
} else {
grep(lineRx, commandLineFiles(os.Args[2:]))
}
}
main()
函数的第一行告诉 Go 运行时系统使用机器可用的所有处理器核心。处理命令行参数(正则表达式和文件列表)后,调用
grep()
函数进行工作协调。
var workers = runtime.NumCPU()
func grep(lineRx *regexp.Regexp, filenames []string) {
jobs := make(chan Job, workers)
results := make(chan Result, minimum(1000, len(filenames)))
done := make(chan struct{}, workers)
go addJobs(jobs, filenames, results) // Executes in its own goroutine
for i := 0; i < workers; i++ {
go doJobs(done, lineRx, jobs) // Each executes in its own goroutine
}
go awaitCompletion(done, results) // Executes in its own goroutine
processResults(results)
// Blocks until the work is done
}
grep()
函数创建三个双向通道:
jobs
通道用于分配任务,
results
通道用于收集结果,
done
通道用于标记任务完成。调用
addJobs()
函数添加任务,启动多个
doJobs()
函数处理任务,调用
awaitCompletion()
函数等待所有任务完成并关闭
results
通道,最后调用
processResults()
函数处理结果。
以下是各函数的具体实现:
-
addJobs()
函数
:
func addJobs(jobs chan<- Job, filenames []string, results chan<- Result) {
for _, filename := range filenames {
jobs <- Job{filename, results}
}
close(jobs)
}
该函数将每个文件名作为
Job
值发送到
jobs
通道,发送完成后关闭通道。
-
doJobs()函数 :
func doJobs(done chan<- struct{}, lineRx *regexp.Regexp, jobs <-chan Job) {
for job := range jobs {
job.Do(lineRx)
}
done <- struct{}{}
}
该函数在多个 goroutine 中调用,遍历
jobs
通道,处理每个任务,任务完成后向
done
通道发送空结构体表示完成。
-
awaitCompletion()函数 :
func awaitCompletion(done <-chan struct{}, results chan Result) {
for i := 0; i < workers; i++ {
<-done
}
close(results)
}
该函数等待所有任务完成,关闭
results
通道。
-
processResults()函数 :
func processResults(results <-chan Result) {
for result := range results {
fmt.Printf("%s:%d:%s\n", result.filename, result.lino, result.line)
}
}
该函数在主 goroutine 中遍历
results
通道,输出结果。
3.3 cgrep 程序并发结构
以下是
cgrep
程序的并发结构示意图:
graph LR
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
classDef channel fill:#FFF6CC,stroke:#FFBC52,stroke-width:2px;
A(Main goroutine: processResults()):::process --> C(results channel):::channel
B1(Goroutine #1: addJobs()):::process --> C1(jobs channel):::channel
B2(Goroutine #2: doJobs()):::process --> C1
B3(Goroutine #3: doJobs()):::process --> C1
B4(Goroutine #4: doJobs()):::process --> C1
B5(Goroutine #5: doJobs()):::process --> C1
B6(Goroutine #6: awaitCompletion()):::process --> C
B2 --> C
B3 --> C
B4 --> C
B5 --> C
B6 --> D(done channel):::channel
B2 --> D
B3 --> D
B4 --> D
B5 --> D
3.4 cgrep 程序变体
cgrep2
程序(
cgrep2/cgrep.go
)是
cgrep1
的变体,没有
awaitCompletion()
和
processResults()
函数,而是使用单个
waitAndProcessResults()
函数:
func waitAndProcessResults(done <-chan struct{}, results <-chan Result) {
for working := workers; working > 0; {
select { // Blocking
case result := <-results:
fmt.Printf("%s:%d:%s\n", result.filename, result.lino,
result.line)
case <-done:
working--
}
}
DONE:
for {
select { // Nonblocking
case result := <-results:
fmt.Printf("%s:%d:%s\n", result.filename, result.lino,
result.line)
default:
break DONE
}
}
}
cgrep3
程序(
cgrep3/cgrep.go
)是
cgrep2
的变体,增加了超时处理:
func waitAndProcessResults(timeout int64, done <-chan struct{},
results <-chan Result) {
finish := time.After(time.Duration(timeout))
for working := workers; working > 0; {
select { // Blocking
case result := <-results:
fmt.Printf("%s:%d:%s\n", result.filename, result.lino,
result.line)
case <-finish:
fmt.Println("timed out")
return // Time's up so finish with what results there were
case <-done:
working--
}
}
for {
select { // Nonblocking
case result := <-results:
fmt.Printf("%s:%d:%s\n", result.filename, result.lino,
result.line)
case <-finish:
fmt.Println("timed out")
return // Time's up so finish with what results there were
default:
return
}
}
}
3.5 任务处理方法
每个任务的处理通过
Job
结构体的
Do()
方法实现:
func (job Job) Do(lineRx *regexp.Regexp) {
file, err := os.Open(job.filename)
if err != nil {
log.Printf("error: %s\n", err)
return
}
defer file.Close()
reader := bufio.NewReader(file)
for lino := 1; ; lino++ {
line, err := reader.ReadBytes('\n')
line = bytes.TrimRight(line, "\n\r")
if lineRx.Match(line) {
job.results <- Result{job.filename, lino, string(line)}
}
if err != nil {
if err != io.EOF {
log.Printf("error:%d: %s\n", lino, err)
}
break
}
}
}
该方法打开文件,逐行读取,使用正则表达式匹配行,将匹配的行作为
Result
发送到
results
通道。
通过以上示例,我们详细了解了 Go 语言中管道模式和多个独立并发任务模式的实现,以及如何使用通道和 goroutine 进行并发编程。这些示例展示了 Go 语言并发编程的灵活性和强大功能。
Go 并发编程示例解析
4. 并发编程模式总结与对比
在前面我们详细介绍了过滤器(Filter)和并发 grep(Concurrent Grep)两个示例,它们分别代表了不同的并发编程模式。下面我们对这两种模式进行总结与对比。
| 模式 | 特点 | 适用场景 | 示例程序 |
|---|---|---|---|
| 管道模式 | 通过通道将多个函数连接成一个流水线,每个函数在独立的 goroutine 中执行,数据依次经过各个函数处理 | 当需要对数据进行一系列的处理步骤,且每个步骤可以独立完成时 | 过滤器(Filter)示例 |
| 多个独立并发任务模式 | 有多个独立的任务,每个任务可以独立完成,通过通道进行任务分配和结果收集 | 当有多个独立的任务需要同时处理,且任务之间不需要通信时 | 并发 grep(Concurrent Grep)示例 |
5. 并发编程的注意事项
在进行 Go 并发编程时,有一些注意事项需要我们牢记:
1.
通道的使用
:
- 通道的缓冲区大小需要根据实际情况进行设置,过大的缓冲区会占用过多的内存,过小的缓冲区可能会导致阻塞。
- 确保在不需要使用通道时及时关闭通道,避免出现死锁或资源泄漏。
2.
共享资源的线程安全
:
- 对于共享的指针类型的值,需要确保其是线程安全的,否则需要使用互斥锁等机制来保证线程安全。
- 例如,在并发 grep 示例中,
*regexp.Regexp
是线程安全的,因此可以在多个 goroutine 中共享使用。
3.
避免 CPU 浪费
:
- 在使用
select
语句时,要避免使用非阻塞的
select
语句导致 CPU 浪费,除非有特殊需求。
- 例如,在并发 grep 示例中,使用阻塞的
select
语句等待结果或任务完成。
6. 并发编程的扩展与优化
并发编程可以根据实际需求进行扩展和优化,以下是一些常见的方法:
1.
增加处理步骤
:
- 在管道模式中,可以增加更多的处理函数,以实现更复杂的数据处理流程。
- 例如,在过滤器示例中,可以添加更多的过滤器函数来进一步筛选文件。
2.
动态调整 goroutine 数量
:
- 在多个独立并发任务模式中,可以根据任务的数量和复杂度动态调整 goroutine 的数量。
- 例如,可以根据文件数量动态创建相应数量的 goroutine 来处理任务。
3.
使用更复杂的通道类型
:
- 可以使用更复杂的通道类型,如带缓冲的通道、单向通道等,以提高程序的性能和可读性。
- 例如,在并发 grep 示例中,使用带缓冲的通道来减少阻塞。
7. 总结
通过本文的介绍,我们深入了解了 Go 语言中的并发编程模式,包括管道模式和多个独立并发任务模式。我们学习了如何使用通道和 goroutine 来实现并发编程,以及在并发编程中需要注意的事项和优化方法。
并发编程是 Go 语言的一大特色,它可以提高程序的性能和响应速度。在实际开发中,我们可以根据具体的需求选择合适的并发编程模式,并结合通道和 goroutine 的使用,编写出高效、稳定的并发程序。
以下是一个简单的 mermaid 流程图,展示了并发编程的一般流程:
graph LR
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
classDef channel fill:#FFF6CC,stroke:#FFBC52,stroke-width:2px;
A(任务初始化):::process --> B(创建 goroutine):::process
B --> C(任务分配):::process
C --> D(任务处理):::process
D --> E(结果收集):::process
E --> F(结果处理):::process
F --> G(任务结束):::process
B -.-> H(通道通信):::channel
D -.-> H
E -.-> H
总之,Go 语言的并发编程为我们提供了强大的工具和灵活的编程模式,通过合理运用这些特性,我们可以更好地应对各种复杂的并发场景。希望本文对你理解和掌握 Go 并发编程有所帮助。
超级会员免费看
1万+

被折叠的 条评论
为什么被折叠?



