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 欢迎大家访问.提意见.
想说的话在没机会开口.
如果大家喜欢我的分享的话,可以关注我的微信公众号 念何架构之路