商业网站设计与制作论文,wordpress投稿页面路径怎么,门户网站开发工具,wordpress实现付费浏览并发
Go 的并发方案#xff1a;goroutine
并行#xff08;parallelism#xff09;#xff0c;指的就是在同一时刻#xff0c;有两个或两个以上的任务#xff08;这里指进程#xff09;的代码在处理器上执行。 并发不是并行#xff0c;并发关乎结构#xff0c;并行关…并发
Go 的并发方案goroutine
并行parallelism指的就是在同一时刻有两个或两个以上的任务这里指进程的代码在处理器上执行。 并发不是并行并发关乎结构并行关乎执行。将程序分成多个可独立执行的部分的结构化程序的设计方法就是并发设计。 Go 并没有使用操作系统线程作为承载分解后的代码片段模块的基本执行单元而是实现了 goroutine 这一由 Go 运行时runtime负责调度的、轻量的用户级线程为并发程序设计提供原生支持。相比传统操作系统线程来说goroutine 的优势主要是 资源占用小每个 goroutine 的初始栈大小仅为 2k由 Go 运行时而不是操作系统调度goroutine 上下文切换在用户层完成开销更小在语言层面而不是通过标准库提供。goroutine 由go关键字创建一退出就会被回收或销毁开发体验更佳语言内置 channel 作为 goroutine 间通信原语为并发设计提供了强大支撑。 和传统编程语言不同的是Go 语言是面向并发而生的所以在程序的结构设计阶段Go 的惯例是优先考虑并发设计。这样做的目的更多是考虑随着外界环境的变化通过并发设计的 Go 应用可以更好地、更自然地适应规模化scale。
goroutine 的基本用法
并发是一种能力它让你的程序可以由若干个代码片段组合而成并且每个片段都是独立运行的。goroutine 恰恰就是 Go 原生支持并发的一个具体实现。 无论是 Go 自身运行时代码还是用户层 Go 代码都无一例外地运行在 goroutine 中。Go 语言通过 go 关键字函数/方法的方式创建一个 goroutine。创建后新 goroutine 将拥有独立的代码执行流并与创建它的 goroutine 一起被 Go 运行时调度。go fmt.Println(I am a goroutine)
var c make(chan int)
go func(a, b int) {c - a b
}(3,4)
// $GOROOT/src/net/http/server.go
c : srv.newConn(rw)
go c.serve(connCtx)通过 go 关键字我们可以基于已有的具名函数 / 方法创建 goroutine也可以基于匿名函数 / 闭包创建 goroutine。 创建 goroutine 后go 关键字不会返回 goroutine id 之类的唯一标识 goroutine 的 id你也不要尝试去得到这样的 id 并依赖它。另外和线程一样一个应用内部启动的所有 goroutine 共享进程空间的资源如果多个 goroutine 访问同一块内存数据将会存在竞争我们需要进行 goroutine 间的同步。 goroutine 的执行函数的返回就意味着 goroutine 退出。 goroutine 执行的函数或方法即便有返回值Go 也会忽略这些返回值。所以如果获取 goroutine 执行后的返回值需要另行考虑其他方法比如通过 goroutine 间的通信来实现。
goroutine 间的通信
传统的编程语言比如C、Java、Python 等并非面向并发而生的所以他们面对并发的逻辑多是基于操作系统的线程。并发的执行单元线程之间的通信利用的也是操作系统提供的线程或进程间通信的原语比如共享内存、信号signal、管道pipe、消息队列、套接字socket等。在这些通信原语中使用最多、最广泛的也是最高效的是结合了线程同步原语比如锁以及更为低级的原子操作的共享内存方式因此我们可以说传统语言的并发模型是基于对内存的共享的。一个符合 CSPCommunicationing Sequential Processes通信顺序进程并发模型的并发程序应该是一组通过输入输出原语连接起来的 P 的集合。 从这个角度来看CSP 理论不仅是一个并发参考模型也是一种并发程序的程序组织方法。它的组合思想与 Go 的设计哲学不谋而合。在 Go 中与“Process”对应的是 goroutine。为了实现 CSP 并发模型中的输入和输出原语Go 还引入了 goroutineP之间的通信原语 channel。goroutine 可以从 channel 获取输入数据再将处理后得到的结果数据通过 channel 输出。通过 channel 将 goroutineP组合连接在一起让设计和编写大型并发系统变得更加简单和清晰我们再也不用为那些传统共享内存并发模型中的问题而伤脑筋了。 虽然 CSP 模型已经成为 Go 语言支持的主流并发模型但 Go 也支持传统的、基于共享内存的并发模型并提供了基本的低级别同步原语主要是 sync 包中的互斥锁、条件变量、读写锁、原子操作等。Go 始终推荐以 CSP 并发模型风格构建并发程序尤其是在复杂的业务层面这能提升程序的逻辑清晰度大大降低并发设计的复杂性并让程序更具可读性和可维护性。不过对于局部情况比如涉及性能敏感的区域或需要保护的结构体数据时我们可以使用更为高效的低级同步原语如 mutex保证 goroutine 对数据的同步访问。
Goroutine 调度器
一个 Go 程序对于操作系统来说只是一个用户层程序操作系统眼中只有线程它甚至不知道有一种叫 Goroutine 的事物存在。所以Goroutine 的调度全要靠 Go 自己完成。那么实现 Go 程序内 Goroutine 之间“公平”竞争“CPU”资源的任务就落到了 Go 运行时runtime头上了。Goroutine 竞争的资源就是操作系统线程。Goroutine调度器的任务就是将 Goroutine 按照一定算法放到不同的操作系统线程中去执行。
深入 G-P-M 模型
G、P 和 M G: 代表 Goroutine存储了 Goroutine 的执行栈信息、Goroutine 状态以及 Goroutine 的任务函数等而且 G 对象是可以重用的P: 代表逻辑 processorP 的数量决定了系统内最大可并行的 G 的数量P 的最大作用还是其拥有的各种 G 对象队列、链表、一些缓存和状态M: M 代表着真正的执行计算资源。 在绑定有效的 P 后进入一个调度循环而调度循环的机制大致是从 P 的本地运行队列以及全局队列中获取 G切换到 G 的执行栈上并执行 G 的函数调用 goexit 做清理工作并回到 M如此反复。M 并不保留 G 状态这是 G 可以跨 M 调度的基础。 Goroutine 调度器的目标就是公平合理地将各个 G 调度到 P 上“运行”。 G 被抢占调度 除非极端的无限循环否则只要 G 调用函数Go 运行时就有了抢占 G 的机会。Go 程序启动时运行时会去启动一个名为 sysmon 的 M一般称为监控线程这个 M 的特殊之处在于它不需要绑定 P 就可以运行以 g0 这个 G 的形式这个 M 在整个 Go 程序的运行过程中至关重要。sysmon 每 20us~10ms 启动一次sysmon 主要完成了这些工作 释放闲置超过 5 分钟的 span 内存如果超过 2 分钟没有垃圾回收强制执行将长时间未处理的 netpoll 结果添加到任务队列向长时间运行的 G 任务发出抢占调度这个事情由函数 retake 实施收回因 syscall 长时间阻塞的 P 如果一个 G 任务运行 10mssysmon 就会认为它的运行时间太久而发出抢占式调度的请求。一旦 G 的抢占标志位被设为 true那么等到这个 G 下一次调用函数或方法时运行时就可以将 G 抢占并移出运行状态放入队列中等待下一次被调度。 两个特殊情况下 G 的调度方法 第一种channel 阻塞或网络 I/O 情况下的调度。 如果 G 被阻塞在某个 channel 操作或网络 I/O 操作上时G 会被放置到某个等待wait队列中而 M 会尝试运行 P 的下一个可运行的 G。如果这个时候 P 没有可运行的 G 供 M 运行那么 M 将解绑 P并进入挂起状态。当 I/O 操作完成或 channel 操作完成在等待队列中的 G 会被唤醒标记为可运行runnable并被放入到某 P 的队列中绑定一个 M 后继续执行。 第二种系统调用阻塞情况下的调度。 如果 G 被阻塞在某个系统调用system call上那么不光 G 会阻塞执行这个 G 的 M 也会解绑 P与 G 一起进入挂起状态。如果此时有空闲的 M那么 P 就会和它绑定并继续执行其他 G如果没有空闲的 M但仍然有其他 G 要去执行那么 Go 运行时就会创建一个新 M线程。当系统调用返回后阻塞在这个系统调用上的 G 会尝试获取一个可用的 P如果没有可用的 P那么 G 会被标记为 runnable之前的那个挂起的 M 将再次进入挂起状态。
作为一等公民的 channel
channel 作为一等公民意味着我们可以像使用普通变量那样使用 channel比如定义 channel 类型变量、给 channel 变量赋值、将 channel 作为参数传递给函数 / 方法、将 channel 作为返回值从函数 / 方法中返回甚至将 channel 发送到其他 channel 中。创建 channel 和切片、结构体、map 等一样channel 也是一种复合数据类型。也就是说我们在声明一个 channel 类型变量时必须给出其具体的元素类型var ch chan int。这里我们声明了一个元素为 int 类型的 channel 类型变量 ch。如果 channel 类型变量在声明时没有被赋予初值那么它的默认值为 nil。 并且和其他复合数据类型支持使用复合类型字面值作为变量初始值不同为 channel 类型变量赋初值的唯一方法就是使用 make 这个 Go 预定义的函数ch1 : make(chan int)
ch2 : make(chan int, 5)这里我们声明了两个元素类型为 int 的 channel 类型变量 ch1 和 ch2并给这两个变量赋了初值。第一行我们通过make(chan T)创建的、元素类型为 T 的 channel 类型是无缓冲 channel而第二行中通过带有 capacity 参数的make(chan T, capacity)创建的元素类型为 T、缓冲区长度为 capacity 的 channel 类型是带缓冲 channel。 发送与接收 Go 提供了-操作符用于对 channel 类型变量进行发送与接收操作ch1 - 13 // 将整型字面值 13 发送到无缓冲 channel 类型变量 ch1 中
n : - ch1 // 从无缓冲 channel 类型变量 ch1 中接收一个整型值存储到整型变量 n 中
ch2 - 17 // 将整型字面值 17 发送到带缓冲 channel 类型变量 ch2 中
m : - ch2 // 从带缓冲 channel 类型变量 ch2 中接收一个整型值存储到整型变量 m 中channel 是用于 Goroutine 间通信的所以绝大多数对 channel 的读写都被分别放在了不同的 Goroutine 中。由于无缓冲 channel 的运行时层实现不带有缓冲区所以 Goroutine 对无缓冲 channel 的接收和发送操作是同步的。 也就是说对同一个无缓冲 channel只有对它进行接收操作的 Goroutine 和对它进行发送操作的 Goroutine 都存在的情况下通信才能得以进行否则单方面的操作会让对应的 Goroutine 陷入挂起状态。对无缓冲 channel 类型的发送与接收操作一定要放在两个不同的 Goroutine 中进行否则会导致 deadlock。 和无缓冲 channel 相反带缓冲 channel 的运行时层实现带有缓冲区因此对带缓冲 channel 的发送操作在缓冲区未满、接收操作在缓冲区非空的情况下是异步的发送或接收不需要阻塞等待。 也就是说对一个带缓冲 channel 来说在缓冲区未满的情况下对它进行发送操作的 Goroutine 并不会阻塞挂起在缓冲区有数据的情况下对它进行接收操作的 Goroutine 也不会阻塞挂起。但当缓冲区满了的情况下对它进行发送操作的 Goroutine 就会阻塞挂起当缓冲区为空的情况下对它进行接收操作的 Goroutine 也会阻塞挂起。 使用操作符 -我们还可以声明只发送 channel 类型send-only和只接收 channel 类型recv-onlych1 : make(chan- int, 1) // 只发送channel类型
ch2 : make(-chan int, 1) // 只接收channel类型
-ch1 // invalid operation: -ch1 (receive from send-only type chan- int
ch2 - 13 // invalid operation: ch2 - 13 (send to receive-only type -chan int通常只发送 channel 类型和只接收 channel 类型会被用作函数的参数类型或返回值用于限制对 channel 内的操作或者是明确可对 channel 进行的操作的类型。 channel 的一个使用惯例是发送端负责关闭 channel。这是因为发送端没有像接受端那样的、可以安全判断 channel 是否被关闭了的方法。同时一旦向一个已经关闭的 channel 执行发送操作这个操作就会引发 panic。 select 当涉及同时对多个 channel 进行操作时我们会结合 Go 为 CSP 并发模型提供的另外一个原语 select一起使用。通过 select我们可以同时在多个 channel 上进行发送 / 接收操作select {
case x : -ch1: // 从 channel ch1 接收数据
... ...
case y, ok : -ch2: // 从 channel ch2 接收数据并根据 ok 值判断 ch2 是否已经关闭
... ...
case ch3 - z: // 将 z 值发送到 channel ch3 中:
... ...
default: // 当上面 case 中的 channel 通信均无法实施时执行该默认分支
}当 select 语句中没有 default 分支而且所有 case 中的 channel 操作都阻塞了的时候整个 select 语句都将被阻塞直到某一个 case 上的 channel 变成可发送或者某个 case 上的 channel 变成可接收select 语句才可以继续进行下去。 无缓冲 channel 的惯用法 第一种用法用作信号传递 无缓冲 channel 用作信号传递的时候有两种情况分别是 1 对 1 通知信号和 1 对 n 通知信号。关闭一个无缓冲 channel 会让所有阻塞在这个 channel 上的接收操作返回从而实现一种 1 对 n 的“广播”机制。 第二种用法用于替代锁机制 无缓冲 channel 具有同步特性这让它在某些场合可以替代锁让我们的程序更加清晰可读性也更好。 带缓冲 channel 的惯用法 带缓冲的 channel 与无缓冲的 channel 的最大不同之处就在于它的异步性。第一种用法用作消息队列 无论是 1 收 1 发还是多收多发带缓冲 channel 的收发性能都要好于无缓冲 channel对于带缓冲 channel 而言发送与接收的 Goroutine 数量越多收发性能会有所下降对于带缓冲 channel 而言选择适当容量会在一定程度上提升收发性能。 第二种用法用作计数信号量counting semaphore Go 并发设计的一个惯用法就是将带缓冲 channel 用作计数信号量counting semaphore。带缓冲 channel 中的当前数据个数代表的是当前同时处于活动状态处理业务的 Goroutine 的数量而带缓冲 channel 的容量capacity就代表了允许同时处于活动状态的 Goroutine 的最大数量。向带缓冲 channel 的一个发送操作表示获取一个信号量而从 channel 的一个接收操作则表示释放一个信号量。 len(channel) 的应用 针对 channel ch 的类型不同len(ch) 有如下两种语义 当 ch 为无缓冲 channel 时len(ch) 总是返回 0当 ch 为带缓冲 channel 时len(ch) 返回当前 channel ch 中尚未被读取的元素个数。 channel 原语用于多个 Goroutine 间的通信一旦多个 Goroutine 共同对 channel 进行收发操作len(channel) 就会在多个 Goroutine 间形成“竞态”。 单纯地依靠 len(channel) 来判断 channel 中元素状态是不能保证在后续对 channel 的收发时 channel 状态是不变的。为了不阻塞在 channel 上常见的方法是将“判空与读取”放在一个“事务”中将“判满与写入”放在一个“事务”中而这类“事务”我们可以通过 select 实现。func producer(c chan- int) {var i int 1for {time.Sleep(2 * time.Second)ok : trySend(c, i)if ok {fmt.Printf([producer]: send [%d] to channel\n, i)icontinue}fmt.Printf([producer]: try send [%d], but channel is full\n, i)}
} func tryRecv(c -chan int) (int, bool) {select {case i : -c:return i, truedefault:return 0, false}
}func trySend(c chan- int, i int) bool {select {case c - i:return truedefault:return false}
}func consumer(c -chan int) {for {i, ok : tryRecv(c)if !ok {fmt.Println([consumer]: try to recv from channel, but the channeltime.Sleep(1 * time.Second)continue}fmt.Printf([consumer]: recv [%d] from channel\n, i)if i 3 {fmt.Println([consumer]: exit)return}}
}func main() {var wg sync.WaitGroupc : make(chan int, 3)wg.Add(2)go func() {producer(c)wg.Done()}()go func() {consumer(c)wg.Done()}()wg.Wait()
}这种方法适用于大多数场合但是这种方法有一个“问题”那就是它改变了 channel 的状态会让 channel 接收了一个元素或发送一个元素到 channel。有些时候我们不想这么做我们想在不改变 channel 状态的前提下单纯地侦测 channel 的状态而又不会因 channel 满或空阻塞在 channel 上。但很遗憾目前没有一种方法可以在实现这样的功能的同时适用于所有场合。但是在特定的场景下我们可以用 len(channel) 来实现。 多发送单接收 也就是有多个发送者但有且只有一个接收者。在这样的场景下我们可以在接收 goroutine 中使用 len(channel) 是否大于 0 来判断是否 channel 中有数据需要接收。 多接收单发送 也就是有多个接收者但有且只有一个发送者。在这样的场景下我们可以在发送 Goroutine 中使用 len(channel) 是否小于 cap(channel) 来判断是否可以执行向 channel 的发送操作。 如果一个 channel 类型变量的值为 nil我们称它为 nil channel。nil channel 有一个特性那就是对 nil channel 的读写都会发生阻塞。 channel 与 select 结合使用的一些惯用法 第一种用法利用 default 分支避免阻塞 select 语句的 default 分支的语义就是在其他非 default 分支因通信未就绪而无法被选择的时候执行的这就给 default 分支赋予了一种“避免阻塞”的特性。 第二种用法实现超时机制 带超时机制的 select是 Go 中常见的一种 select 和 channel 的组合用法。通过超时事件我们既可以避免长期陷入某种操作的等待中也可以做一些异常处理工作。func worker() {select {case -c:// ... do some stuffcase -time.After(30 *time.Second):return}
}我们要尽量减少在使用 Timer 时给 Go 运行时和 Go 垃圾回收带来的压力要及时调用 timer 的 Stop 方法回收 Timer 资源。 第三种用法实现心跳机制 结合 time 包的 Ticker我们可以实现带有心跳机制的 select。这种机制让我们可以在监听 channel 的同时执行一些周期性的任务func worker() {heartbeat : time.NewTicker(30 * time.Second)defer heartbeat.Stop()for {select {case -c:// ... do some stuffcase - heartbeat.C://... do heartbeat stuff}}
}如何使用共享变量
sync 包低级同步原语可以用在哪
首先是需要高性能的临界区critical section同步机制场景。 在 Go 中channel 并发原语也可以用于对数据对象访问的同步我们可以把 channel 看成是一种高级的同步原语它自身的实现也是建构在低级同步原语之上的。也正因为如此channel 自身的性能与低级同步原语相比要略微逊色开销要更大。 第二种就是在不想转移结构体对象所有权但又要保证结构体内部状态数据的同步访问的场景。 基于 channel 的并发设计有一个特点在 Goroutine 间通过 channel 转移数据对象的所有权。所以只有拥有数据对象所有权从 channel 接收到该数据的 Goroutine 才可以对该数据对象进行状态变更。如果设计中没有转移结构体对象所有权但又要保证结构体内部状态数据在多个 Goroutine 之间同步访问那么可以使用 sync 包提供的低级同步原语来实现比如最常用的 sync.Mutex。
sync 包中同步原语使用的注意事项
Go 标准库中 sync.Mutex 的定义是这样的// $GOROOT/src/sync/mutex.go
type Mutex struct {state int32sema uint32
}Mutex 的定义非常简单由两个整型字段 state 和 sema 组成state表示当前互斥锁的状态
sema用于控制锁状态的信号量。初始情况下Mutex 的实例处于 Unlocked 状态state 和 sema 均为 0。对 Mutex 实例的复制也就是两个整型字段的复制。一旦发生复制原变量与副本就是两个单独的内存块各自发挥同步作用互相就没有了关联。如果发生复制后仍然认为原变量与副本保护的是同一个数据对象那可就大错特错了。一旦 Mutex 类型变量被拷贝原变量与副本就各自发挥作用互相没有关联了。甚至如果拷贝的时机不对比如在一个 mutex 处于 locked 的状态时对它进行了拷贝就会对副本进行加锁操作将导致加锁的 Goroutine 永远阻塞下去。如果对使用过的、sync 包中的类型的示例进行复制并使用了复制后得到的副本将导致不可预期的结果。所以在使用 sync 包中的类型的时候我们推荐通过闭包方式或者是传递类型实例或包裹该类型的类型实例的地址指针的方式进行。这就是使用 sync 包时最值得我们注意的事项。 sync 包提供了两种用于临界区同步的原语互斥锁Mutex和读写锁RWMutex。 它们都是零值可用的数据类型也就是不需要显式初始化就可以使用并且使用方法都比较简单。var mu sync.Mutex
mu.Lock() // 加锁
doSomething()
mu.Unlock() // 解锁一旦某个 Goroutine 调用的 Mutex 执行 Lock 操作成功它将成功持有这把互斥锁。这个时候如果有其他 Goroutine 执行 Lock 操作就会阻塞在这把互斥锁上直到持有这把锁的 Goroutine 调用 Unlock 释放掉这把锁后才会抢到这把锁的持有权并进入临界区。由此我们也可以得到使用互斥锁的两个原则 尽量减少在锁中的操作。这可以减少其他因 Goroutine 阻塞而带来的损耗与延迟。一定要记得调用 Unlock 解锁。忘记解锁会导致程序局部死锁甚至是整个程序死锁会导致严重的后果。 读写锁与互斥锁用法大致相同只不过多了一组加读锁和解读锁的方法var rwmu sync.RWMutex
rwmu.RLock() //加读锁
readSomething()
rwmu.RUnlock() //解读锁
rwmu.Lock() //加写锁
changeSomething()
rwmu.Unlock() //解写锁写锁与 Mutex 的行为十分类似一旦某 Goroutine 持有写锁其他 Goroutine 无论是尝试加读锁还是加写锁都会被阻塞在写锁上。读锁就宽松多了一旦某个 Goroutine 持有读锁它不会阻塞其他尝试加读锁的 Goroutine但加写锁的 Goroutine 依然会被阻塞住。通常互斥锁Mutex是临时区同步原语的首选它常被用来对结构体对象的内部状态、缓存等进行保护是使用最为广泛的临界区同步原语。相比之下读写锁的应用就没那么广泛了只活跃于它擅长的场景下。读写锁适合应用在具有一定并发量且读多写少的场合。在大量并发读的情况下多个 Goroutine 可以同时持有读锁从而减少在锁竞争中等待的时间。 条件变量 sync.Cond是传统的条件变量原语概念在 Go 语言中的实现。 我们可以把一个条件变量理解为一个容器这个容器中存放着一个或一组等待着某个条件成立的 Goroutine。当条件成立后这些处于等待状态的 Goroutine 将得到通知并被唤醒继续进行后续的工作。 条件变量是同步原语的一种如果没有条件变量开发人员可能需要在 Goroutine 中通过连续轮询的方式检查某条件是否为真这种连续轮询非常消耗资源因为 Goroutine 在这个过程中是处于活动状态的但它的工作又没有进展。
原子操作atomic operations
atomic 包是 Go 语言给用户提供的原子操作原语的相关接口。原子操作atomic operations是相对于普通指令操作而言的。原子操作由底层硬件直接提供支持是一种硬件实现的指令级的“事务”因此相对于操作系统层面和 Go 运行时层面提供的同步技术而言它更为原始。atomic 包封装了 CPU 实现的部分原子操作指令为用户层提供体验良好的原子操作函数因此 atomic 包中提供的原语更接近硬件底层也更为低级它也常被用于实现更为高级的并发同步技术比如 channel 和 sync 包中的同步原语。atomic 包提供了两大类原子操作接口一类是针对整型变量的包括有符号整型、无符号整型以及对应的指针类型另外一类是针对自定义类型的。因此第一类原子操作接口的存在让 atomic 包天然适合去实现某一个共享整型变量的并发同步。atomic 原子操作的特性随着并发量提升使用 atomic 实现的共享变量的并发读写性能表现更为稳定尤其是原子读操作和 sync 包中的读写锁原语比起来atomic 表现出了更好的伸缩性和高性能。atomic 包更适合一些对性能十分敏感、并发量较大且读多写少的场合。不过atomic 原子操作可用来同步的范围有比较大限制只能同步一个整型变量或自定义类型变量。如果我们要对一个复杂的临界区数据进行同步那么首选的依旧是 sync 包中的原语。