Go并发编程同步和管道

0 阅读11分钟

1.同步:

内核对进程的切换和调度使得多个进程可以有条不紊地并发运行.很多时候.多个进程

之间需要相互配合共同完成一个任务.这就需要IPC的支持.在了解IPC方法之前.先了

解一下通信过程中可能发生的干扰.这种干扰主要集中在共享数据的情况下.

问题:

计数器.它由进程A创建并与进程B共享.进程A和进程B实际上执行了相同的程序.这个

程序的任务是把符合某些条件的数据从数据库迁移到磁盘上.程序总是按照固定顺序

从数据库中查询数据.并使用计数器记录的已查询的数据的最大行号作为依据.

执行步骤:

1).读取计数器的值.

2).从数据库查询数据.如果用c来代表计数器的值.查询的范围就是行号在

[c,c+100000]的数据.也就是说.每次查询10万条数据.

3).遍历并筛选出符合条件的数据.并组成新的数据集合.

4).将新数据集合存储到指定目录的文件中.该文件的名称总是有一致的主名称data.

并会以递增的序号作为后缀.例如data1 data2等等.

5).把计数器的值加100000.计数器的新值就是下次要查询的数据的首行行号.

6).检查数据是否已全部读完.如果是则直接退出.否则跳转回(1).

进程A和进程B会并发运行.它们会各自循环往复的迁移它们认为的下一个数据集合.直

到数据全部迁移完毕.

问题过程分析:

1).内核使CPU运行进程A.

2).进程A读取计数器的值1.并依此查询和筛选数据.得到新的数据集合.

3).内核认为进程A已经运行了足够长的时间.所以它把进程A换下并让CPU开始运行

进程B.

4).进程B读取计数器的值1.并依此查询和筛选数据.得到了新的数据集合.这个数据集

合与进程A刚刚得到的数据集合完全一样.

5).进程B把得到的数据集合写入名称为data1的文件.并在写入完成后关闭文件.

6).内核把进程B换下并让CPU开始运行进程A.

7).进程A把得到的数据集合写入名称为data1的文件.并在写入完成后关闭文件.

8).进程A把计数器的值更新为100001.

9).内核把进程A换下并让CPU开始运行进程B.

10).进程B把计数器的值更新为100001.

流程图:

可以看出进程A和进程B在做重复的事情.造成了双倍的资源消耗.并导致了事倍功半的

结果.这是由于同一个进程对计数器的值存取跨度太大.以至于计数器只起到了任务进

度记录的作用.没有起到进程间协调的作用.

解决:

多个进程同时对同一个资源进行访问.就很有可能相互干扰.这种干扰通常称为竟态条

件.竟态条件的根本原因在于进程进行某些操作的时候被中断了.虽然进程再次运行时

其状态会恢复如初.但是外界环境很可能已经在这极短的时间内发生了改变.上述的计

数器几乎已经完全奏效了.但就是由于应用程序对进程调度不可控性使得竟态条件仍

然可能发生.如果能保证获取并更新计数器的值是一个原子操作的话.那么竟态条件不

会发生.更具体说.如果进程A在获取并更新计数器的值过程中不被中断.那么进程B就

会去处理行号在[100001,200000]范围内的数据了.


执行过程中不能中断的操作称为原子操作.只能被串行化访问或执行的某个资源或某

段代码称为临界区.

注:所有系统调用都属于原子操作.不用担心执行会被中断.

原子操作和临界区这两个概念看起来有些相似.原子操作是不能中断的.而临界区对是

否可被中断却没有强制规定.只要保证一个访问者在临界区中时其他访问者不会被放

进来.意味着它们强度是不同的.

原子操作必须由一个单一的汇编指令表示.并且需要得到芯片级别的支持.当今的CPU

都提供了对原子操作的支持.即使在多核CPU或CPU的计算机系统中.也可以保证原子

操作的正确执行.使得原子操作能够做到绝对的并发安全.并且比其他同步机制要快很

多.

如果一个原子操作的执行总是无法结束而又无法中断它.这也是内核只提供针对二进

制为和整数原子操作的原因.原子操作只适合细粒度的简单操作.Go也在CPU和各个

操作系统的底层支撑之上提供了对原子操作的支持.具体来讲就是标准代码包

sync/atomic中一些函数.

相比原子操作.让串行化执行的若干代码形成临界区这种方法更通用.保证只有一个进

程或线程在临界区之内的做法有一个官方称谓-----互斥.实现互斥的方法必须保证排

它原则.并且这种保证不能依赖于任何计算机硬件(包括CPU).也就是说.互斥方法必须

有效且通用.

2.管道:

通道是一种半双工的通信方式.只能用于父进程与子进程及同祖先子进程之间的通信.

在使用shell命令的时候.常常会用到管道.shell为每个命令都创建一个进程.然后把左

边命令的标准输出用管道与右边命令的标准输入连接起来.

管道的优点在于简单.缺点则是只能单向通信以及对通信双方关系上的严格限制.

对于通道.Go是支持的.通过标准库代码包os/exec中的API.可以执行操作系统命令

并在此之上建立管道.

exec.Cmd类型示例:

func main() {
    cmd0 := exec.Command("echo", "-n", "my first command comes from golang.")
}

对应shell命令:

echo -n "my first command comes from golang."

在exec.Cmd类型之上有一个名为Start的方法.可以使用它启动命令.

func main() {
    cmd0 := exec.Command("echo", "-n", "my first command comes from golang.")
    err := cmd0.Start()
    if err != nil {
       fmt.Printf("Error: %v\n", err)
       return
    }
}

为了创建一个能够获取此命令的输出管道.需要在if语句之前加入如下语句.

stdou0, err2 := cmd0.StdoutPipe()
if err2 != nil {
    fmt.Printf("Error creating stdout pipe: %v\n", err2)
    return
}

变量cmd0的StdoutPipe方法会返回一个输出管道.这里代表把这个输出管道的值赋

值给了变量stdou0.类型是io.ReadCloser.后者是一个扩展了io.Reader接口的接口

类型.并定义了可关闭的数据读取行为.

有了stdout0后.启动上述命令以后就可以通过调用它的Read方法来获取命令的输

出.

output0 := make([]byte, 30)
n, err2 := stdou0.Read(output0)
if err2 != nil {
    fmt.Printf("Error reading stdout: %v\n", err2)
    return
}
fmt.Printf("Output: %v\n", string(output0[:n]))

这里的Read方法会把读出的输出数据存入调用方传递给它的字节切片中.并返回一个

int类型的值和一个error类型值.如果命令的输出小于output0的长度.那么变量n的

值会是命令实际输出的字节数量.否则n的值就等于output0的长度.后一种情况常常

意味着并没有完全读出输出管道中的数据.这时需要再去读取一次或多次.(可以使用

for语句进行循环读取).如果输出管道中再没有可以读取的数据.那么Read方法返回

的第二个结果值就会是变量io.EOF的值.可以依次判断是否读完.

var outputBuf bytes.Buffer
for {
    tempOutput := make([]byte, 5)
    n, err2 := stdou0.Read(tempOutput)
    if err2 != nil {
       if err2 == io.EOF {
          break
       } else {
          fmt.Printf("Error: %v\n", err2)
       }
    }
    if n > 0 {
       outputBuf.Write(tempOutput)
    }
}
fmt.Printf("%s\n", outputBuf.String())

为了达到效果,把字节切片tempOutput的长度设置的很小.为了收集每次迭代读到的

输出内容.它们会依次被存放到一个缓冲区outputBufo中.

一个更便捷的方法.是一开始就使用带缓冲的读取器从输出管道中读取.

outputBuf := bufio.NewReader(stdou0)
output0, _, err2 := outputBuf.ReadLine()
if err2 != nil {
    fmt.Printf("Error reading output: %v\n", err2)
}
fmt.Printf("%s\n", string(output0))

由于stdout0的值也是io.Reader类型的.所以可以把它作为bufio.NewReader函

数的参数.这个函数会返回一个bufio.Reader类型的值.也就是一个缓冲读取器.默认

情况下.该读取器会携带一个长度为4096的缓冲区.缓冲区的长度就代表了一次可以

读取的字节的最大数量.由于cmd0代表的命令只会输出一行内容.所以可以直接使用

outputBuf的ReadLine()方法来读取.这个方法的第二个bool类型的值是表明当前

行是否还未读完.如果它为false.那么利用for语句来读出剩余的数据.


管道可以把一个命令输出作为另一个命令的输入.Go也可以做到.

func main() {
    cmd1 := exec.Command("ps", "aux")
    cmd2 := exec.Command("grep", "apipe")
}

设置cmd1的Stdout字段.然后启动cmd1.并等待它运行完毕.

var outputBuf1 bytes.Buffer
cmd1.Stdout = &outputBuf1
err := cmd1.Start()
if err != nil {
    fmt.Printf("Error starting cmd: %s\n", err)
    return
}
err = cmd1.Wait()
if err != nil {
    fmt.Printf("Error waiting for cmd: %s\n", err)
    return
}

因为bytes.Buffer实现了io.Writer接口.所以才能把&outputBuf1赋给

cmd1.Stdout.命令cmd1启动后的所有输出内容就会被写入到

outputBuf1.cmd.Wait方法的调用会一直阻塞.直到cmd1执行完成.

cmd2.Stdin = &outputBuf1
var outputBuf2 bytes.Buffer
cmd2.Stdout = &outputBuf2
err = cmd2.Start()
if err != nil {
    fmt.Printf("Error starting cmd: %s\n", err)
    return
}
err = cmd2.Wait()
if err != nil {
    fmt.Printf("Error waiting for cmd: %s\n", err)
    return
}

*bytes.Buffer类型也实现了io.Reader接口.才能把&outputBuf1也赋给

cmd2.Stdin.因为两次赋值.cmd2的输入才能与cmd1的输出串联在一起.这个媒介

正式outputBuf1.它起到了管道的作用.


命名管道:

命名管道默认是阻塞的.只有在对这个命令管道的读操作和写操作都准备就绪后.数据

才开始流转.还要注意命名管道仍然是单向的.又由于可以实现多路复用.所以有时候也

需要考虑多个进程同时向命名管道写数据情况下的操作原子性问题.

在Go标准代码包os中.包含了可以创建这种独立管道的API.

func main() {
    r, w, err := os.Pipe()
}

函数os.Pipe会返回三个结果值.第一个结果值代表了该管道输出端的os.File类型的*

值.第二结果则代表了该管道输入端的os.File类型的值.它们共同成为数据传递的渠*

道.第三个结果error代表可能发生的错误.无错误发生.值为nil.

func main() {
    r, w, err := os.Pipe()
    n, err := w.Write([]byte("hello"))
    if err != nil {
       fmt.Printf("error writing: %s\n", err)
       return
    }
    fmt.Printf("wrote %d bytes\n", n)

    n, err = r.Read(make([]byte, 1024))
    if err != nil {
       fmt.Printf("error writing: %s\n", err)
    }
    fmt.Printf("read %d bytes\n", n)
}

如果上面的读取和写入是并发执行的.在reader之上调用Read方法就可以按照顺序

获取到之前写入的数据.为什么强调并发运行.因为命名管道默认会在一端还未就绪的

时候阻塞另一端的进程.Go提供的命名管道的行为特征也是如此.

因为管道都是单向的.所以虽然r和w都是os.File类型的.但却不能调用reader的那些*

写方法或读方法.否则就会得到非nil的错误值.错误信息会告诉我们.这样访问是不允

许的.实际上.在exec.Cmd类型的值之上调用StdinPipe或StdoutPipe方法后.得到

的输入管道或输出管道也是通过os.Pipe函数生成的.只不过.在这两个方法内部又对

生成的管道做了少许附加处理.


语雀地址www.yuque.com/itbosunmian…?

《Go.》 密码:xbkk 欢迎大家访问.提意见.


想说的话在没机会开口.

如果大家喜欢我的分享的话,可以关注我的微信公众号 念何架构之路