并发的基础知识
进程与线程的回顾总结:
进程的定义:
进程比较通用的几个定义:
- 进程是程序的一次执行过程
- 进程是一个程序及其数据在处理机上顺序执行时所发生的活动
- 进程是具有独立功能的程序在一个数据集合上运行的过程,它是系统进行资源分配和调度的一个独立单位
从定义上可以看出,进程实际上是一个过程的描述,它强调运行的程序的这个过程,而不是像程序那样的物理实体。。
进程的基本特性:
- 动态性:正如上面描述的,进程是一个进程实体的执行过程
- 并发性:多个实体进程同时存在与内存中,可以在一个时间段内同时运行。并发不是并行,并行是指同一时刻同时发生,而并发只是在一个时间段内。
- 独立性:每个进程可以独立运行、独立获得资源和独立接受处理机的调度。每个进程的资源不允许其他进程访问。
- 异步性:每个进程是各自独立地,按照不可预知的速度进行推进
进程的基本状态
- 就绪状态:进程已经拥有除了CPU之外的所有执行所需的资源,只要获得CPU就能执行
- 执行状态:正在CPU上执行
- 阻塞状态:执行的进程由于发生某事件(I/O处理、申请缓存失败等),暂时无法继续执行的状态。
线程的定义
线程可以视为进程的一个任务,相当于一个更加轻量级的进程。在引入线程的OS中,线程是运行的基本单位。一个进程可以有多个线程,线程之间可以并发或者并行执行;进程之间可以共享线程的资源;每个进程可以访问所属线程的所有空间;系统开销远远小于进程。
进程线程之间的关联
主要参考了这篇博客
对操作系统来说,线程是最小的执行单元,进程是最小的资源管理单元.
进程与线程的关系:
进程和线程的几个转换状态:
并发主流的实现模型
- 多进程,并发的基本模式
- 多线程,大部分操作系统属于系统层面的并发模式,使我们使用的最多也是最有效的模式
- 基于回调的异步I/O操作,
Nodejs
采用的这种方式,事件循环,异步I/O模式。但是编程的时候需要大量的回调操作。 - 协程,一种更加轻量级的线程。一个线程可以有多个协程,系统开销极小,不需要操作系统进行抢占式调度。
多线程的实现借助于“共享内存系统”,而Golang的协程借助于消息传递系统,发送消息的时候对状态进行复制,并在消息传递的边界上交出这个状态的所有权。
协程和goroutine
协程与进程想、线程的关系:
进程和线程都是由操作系统控制,进程、线程的切换需要操作系统的内核来管理;而协程是由程序本身控制的,所以使用的代价极低。
goroutine
是Go语言中的协程,需要使用关键字go
来实现。使用了go
的函数,在调用时就会在goroutine
中执行,函数返回则goroutine
结束,如果函数有返回值,则丢弃返回值。当main
函数返回时,程序退出,而且程序不等待其他的goroutine
(非主goroutine
)结束。
代码:
package main
import (
"fmt"
)
func Add(x, y int) {
z := x + y
fmt.Println(z)
}
func main() {
for i := 0; i < 10; i++ {
go Add(i, i)
}
}
代码中的函数不会有任何输出,因为主函数启动了10个goroutine
后,就退出了,那10个goroutine
还没来得及执行。。
并发通信
- 共享数据模型:可以理解成加锁的那种模式,用户的线程共享内存单元
- 共享消息模型:每个并发的单元是独立的个体,他们之间不共享变量等的数据,每个并发单元之间唯一的输入和输出是“消息”,这也是他们唯一的通信方式,不共享内存。
channel
Golang使用channel
提供goroutine
之间的通信,是类型相关的,一个channel
只能传递一种类型的值,需要提前声明。但是,涉及到跨进程通信时,最好采用分布式的方式解决,比如Socket
等的协议。
基本语法
声明:
var chaName chan ElementType
代码实例:
var ch chan int // 整型的
var m map[string] chan bool // 注意map型的
定义:
ch := make(chan int) // 定义int型的channel
写入数据:
ch <- value // value数据写入channel
注意:向channel
写入数据的操作必须在一个goroutine
中执行(即使用关键字go
),否则程序报错!
写入数据通常会阻塞程序,知道有其他的goroutine
从channel
中读取数据。
读取数据:
value := <-ch
如果channel
中没有数据,则goroutine
也会阻塞,直到channel
中被写入数据为止
select
操作
基本语法示例:
select {
case <-chan1:
// 如果chan1读到数据,则进行该case处理
case <-chan2:
// 如果chan2读到数据,则进行该case处理
case chan3 <- value:
// 如果写入数据到chan3,则进行该case处理
default:
// 都没成功,则在这里处理
}
每个case
后面必须是面向channel
的操作。
缓冲机制
用于创建channel
缓冲队列,适合大规模的数据传输场景:
c := make(chan int, 1024) // 创建了含有1024个channel的队列
这样,即使没有读取方,写入方也可以一直往channel
里面写入数据,缓冲区填满之前不会发生阻塞。
读取方式:
for i := range c {
fmt.Println("Received: ", i)
}
超时机制
适用于向channel
写数据时,channel
已满;或者从channel
读数据,channel
已空。防止产生死锁。借助于select
函数实现:
timeout := make(chan bool, 1)
go func() {
time.Sleep(1e9) // 等待一秒钟
timeout <-true
}()
select {
case <-ch:
// 从ch中读取数据
case <-timeout:
// 一直没有从ch中读到数据,但是从timeout中读到了数据
}
channel
的传递
Go语言的channel
是一个基本类型,地位等同于map
之类的。channel
本身定义后也可以通过channel
来传递,可以用这个事项管道pipe
的特性。管道的知识。但是,GO语言的管道是类型相关的,只能传递一种类型的数据。
type PipeData struct {
value int
/*
* 通用的模式是在这里添加自己需要的数据结构
*/
handler func(int) int
next chan int
}
func handle(queue chan *PipeData) {
for data := range queue {
data.next <- data.handler(data.value)
}
}
代码的解读:
PipeData
结构体中封装了数据value
;handler
是一个函数指针,用来处理数据,传输和返回值都是int
型的,因为Go语言的管道是传递同一种类型的数据;next
是一个channel
类型,用于存储整型的数据,把每个程序的next
相互链接,就组成了管道。
单向channel
单向的channel
只能用于发送数据或者只能接收数据。用channel
的类型转换机制,实现专门的读或者写的channel
。
var ch1 chan int // 普通的channel
var ch2 chan<- float64 // 只能写数据的单向channel
var ch3 <-chan int // 只能读数据的单向channel
ch4 := make(chan, int)
ch5 := <-chan int(ch4) // 转换成只读的channel
ch6 := chan<- int(ch4) // 转换成只写的channel
这是为了遵循最小权限的准则:
func Parse(ch <-chan int) {
for value := range ch {
fmt.Println("Parsing value", value)
}
}
方式误写入数据,因为可以理解为channel
是传递的引用。。。
关闭channel
使用close()
函数关闭channel
。
close(ch1)
x, ok := close(ch2) // 只需要看第二个ok,如果是false,那么表示成功关闭。
关闭channel
后,不能再向channel
中写入数据了,但是可以从中读取残余的数据!
关于channel
常用的操作
简单的同步操作
等待同步,等待list
排序完成后,再去做其他事情,代码示例:
c := make(chan int) // Allocate a channel.
// Start the sort in a goroutine; when it completes, signal on the channel.
go func() {
list.Sort()
c <- 1 // Send a signal; value does not matter.
}()
doSomethingForAWhile()
<-c // Wait for sort to finish; discard sent value.
并发任务模式,作为信号量集
channel
作为一个信号量使用,实现生产者和消费者模式,代码实例:
var sem = make(chan int, MaxOutstanding)
func handle(r *Request) {
sem <- 1 // Wait for active queue to drain.
process(r) // May take a long time.
<-sem // Done; enable next request to run.
}
func Serve(queue chan *Request) {
for {
req := <-queue
go handle(req) // Don't wait for handle to finish.
}
}
上述代码中,handle
函数用于处理请求;有一个信号量集sem
,容量是MaxOutstanding
,表示最大的吞吐量;利用channel
自身的读写阻塞机制,可以实现最多MaxOutstanding
的并发操作。
但是,上述代码的Server
存在一些不足:虽然最多同时运行MaxOutstanding
个goroutine
,但是如果请求来的过快,会导致程序创建出过多的goroutine
。即使它们等待运行,但是它们会占用系统的其他资源的。下面给出改进的方案:
func Serve(queue chan *Request) {
for req := range queue {
sem <- 1
go func(req *Request) {
process(req)
<-sem
}(req) // 在这里的这一句,可以看成一个原子操作,防止req被多个goroutine共享
}
}
// 或者使用下面的等效操作
func Server(queue chan *Request) {
for req := range queue {
req := req // 在闭包内使用局部的,防止共享
sem <- 1
gon func() {
process(req)
<-sem
}()
}
}
另外一种方式,让handle
一次性处理一个批次的请求,然后在Server
函数中确定goroutine
的个数。这样,goroutine
的数量就限制了一次性调用process
函数的次数。quit
变量是用于控制退出的。
func handle(queue chan *Request) {
for r := range queue {
process(r)
}
}
func Serve(clientRequests chan *Request, quit chan bool) {
// Start handlers
for i := 0; i < MaxOutstanding; i++ {
go handle(clientRequests)
}
<-quit // Wait to be told to exit.
}
Channels
of channels
客户端代码:
type Request struct {
args []int
f func([]int) int
resultChan chan int
}
func sum(a []int) (s int) {
for _, v := range a {
s += v
}
return
}
request:= &Request{[]int{3, 4, 5}, sum, make(chan int)}
// 发送请求
clientRequests <-request
// 等待服务器回应
fmt.Println("answer: %d\n", <-request.resultChan)
服务端代码:
func handle(queue chan *Request) {
for req := range queue {
req.resultChan <- req.f(req.args)
}
}
多核并行化和出让时间片
计算N个整数的和,把所有的整数分解成M份,M是CPU的数量。让每个CPU计算分给它的那份计算任务,最后把结果做一个累加。
多核并行化代码实例:
type Vector []float64
// // Apply the operation to v[i], v[i+1] ... up to v[n-1]
func (v Vector) DoSome(i, n int, u Vector, c chan int) {
for ; i < n; i++ {
v[i] += u.Op(v[i])
}
c <- 1 // 完成的信号
}
const NCPU = 16
func (v Vector) DoAll(u Vector) {
c := make(chan int, NCPU) // 用于接收每个CPU完成的信号
for i := 0; i < NCPU; i++ {
go v.DoSome(i*len(v)/NCPU, (i+1)*len(v)/NCPU, u, c)
}
for i := 0; i < NCPU; i++ {
<-c // 取到一个数据,表示计算完成
}
}
同步
这里主要是处理多个goroutine
之间共享数据的问题。
同步锁
sync
包中提供了两种类型锁:
sync.Mutex
:当一个goroutine
获得该锁后,其它的goroutine
只能等它释放这个锁sync.RWMutex
:这是单写多读模式,读锁占用的情况下,会阻止,但不会阻止读,多个goroutine
可以同时获得读锁(调用RLock()
方法);写锁(调用Lock()
方法)阻止其他任何goroutine
读写。
任何一个Lock()
或者RLock()
均需要有对应的UnLock()
和RUnLock()
方法与之对应,否则可能导致死锁。一般使用defer
关键字解决这个问题:
var l sync.Mutex
func foo() {
l.Lock()
defer l.UnLock()
}
全局唯一性操作
用于处理全局的一次性操作,使用Once
类型解决:
var a string
var once sync.Once
func setup() {
a = "hello world !"
}
func doprint() {
once.Do(setup)
print(a)
}
func twoprint() {
go doprint()
go doprint()
}
once
的Do()
方法可以保证在全局值执行一次,其他的goroutine
执行到这一句时,会先被阻塞,知道全局唯一的once.Do()
调用结束后才返回。