go 通道 并发 顺序
Go普及了口头禅: 不要通过共享内存来交流; 通过通信共享内存。 该语言确实具有传统的互斥体(互斥结构)来协调对共享内存的访问,但它偏爱使用通道在goroutine之间共享信息。
在本文中,对goroutines,线程和竞争条件的简要介绍为我们介绍了两个Go程序奠定了基础。 在第一个程序中,goroutine通过同步的共享内存进行通信,第二个程序出于相同目的使用通道。 可从我的网站以包含自述文件的.zip文件形式获得该代码。
线程和竞争条件
线程是一系列可执行指令,并且同一进程中的线程共享一个地址空间:多线程进程中的每个线程都具有对相同内存位置的读/写访问权限。 如果两个或多个线程(其中至少一个线程执行写操作)对同一内存位置进行不协调的访问,则会发生基于内存的竞争情况 。
考虑一下对整数变量n
描述,其值为777,并且有两个线程试图更改其内容:
n
= n
+
10
+
----
-+ n
= n
-
10
Thread1
------------ >
|
777
| <
------------ Thread2
+
----
-+
n
在多处理器机器上,这两个线程可以同时执行。 这样,对变量n
的影响就不确定了。 请务必注意,每次尝试进行的更新都包括两个机器级操作:对n
的当前值进行算术运算(加或减10),以及随后的将n
设置为新值的赋值操作(787或767) )。
在两个线程中执行的配对操作可能以各种不适当的方式交错。 请考虑以下情形,其中每个编号的项目在计算机级别都是单个操作。 为简单起见,假设每个操作占用系统时钟的一个刻度:
- 线程1对计算787进行加法运算,该运算被保存在临时位置(在堆栈中或在CPU寄存器中)。
- Thread2进行减法运算以计算767,该运算也保存在一个临时位置。
- Thread2执行分配;
n
的值现在是767。 - 线程1执行分配;
n
的值现在是787。
通过排在最后,Thread1赢得了与Thread2的比赛。 很明显,发生了不正确的交织。 Thread1执行加法运算,延迟两个滴答,然后执行分配。 相比之下,Thread2在不中断的情况下执行减法和后续赋值操作。 解决方法很明确:算术和赋值操作应该像单个原子操作一样进行。 互斥锁之类的构造提供了所需的修复,而Go具有互斥锁。
Go程序通常是多线程的,尽管线程发生在表面之下。 表面上是goroutines。 goroutine是绿色线程-Go运行时控件下的线程。 相比之下, 本机线程直接受OS控制。 但是goroutines可以多路复用到OS调度的本机线程上,这意味着Go中可能有基于内存的竞争条件。 两个示例程序中的第一个说明了这一点。
MiserSpendthrift1
MiserSpendthrift1程序模拟对银行帐户的共享访问。 除了main
,还有两个其他goroutine:
- 守财奴够程在一个时间反复增加了平衡,一个货币单位。
- 节俭的 goroutine反复从余额中减去,一次也减去一个货币单位。
每个goroutine执行其操作的次数取决于命令行参数,该参数应足够大以至于令人感兴趣(例如,100,000到几百万)。 帐户余额被初始化为零,并且应该结为零,因为存款和取款的金额相同且数量相同。
示例1.使用互斥锁协调对共享内存的访问
package main
import
(
"os"
"fmt"
"runtime"
"strconv"
"sync"
)
var accountBalance
=
0
// balance for shared bank account
var mutex
= &
sync. Mutex
{}
// mutual-exclusion lock
// critical-section code with explicit locking/unlocking
func updateBalance
( amt
int
)
{
mutex
. Lock
()
accountBalance
+= amt
// two operations: update and assignment
mutex
. Unlock
()
}
func reportAndExit
( msg
string
)
{
fmt
. Println
( msg
)
os
. Exit
(
-
1
)
// all 1s in binary
}
func main
()
{
if
len
( os
. Args
) <
2
{
reportAndExit
(
" \n Usage: go ms1.go <number of updates per thread>"
)
}
iterations
, err
:= strconv
. Atoi
( os
. Args
[
1
])
if err
!=
nil
{
reportAndExit
(
"Bad command-line argument: "
+ os
. Args
[
1
]);
}
var wg sync
. WaitGroup
// wait group to ensure goroutine coordination
// miser increments the balance
wg
. Add
(
1
)
// increment WaitGroup counter
go
func
()
{
defer wg
. Done
()
// invoke Done on the WaitGroup when finished
for
i
:=
0
;
i < iterations
;
i
++
{
updateBalance
(
1
)
runtime
. Gosched
()
// yield to another goroutine
}
}()
// spendthrift decrements the balance
wg
. Add
(
1
)
// increment WaitGroup counter
go
func
()
{
defer wg
. Done
()
for
i
:=
0
;
i < iterations
;
i
++
{
updateBalance
(
-
1
)
runtime
. Gosched
()
// be nice--yield
}
}()
wg
.
Wait
()
// await completion of miser and spendthrift
fmt
.
Println
(
"Final balance: "
, accountBalance
)
// confirm final balance is zero
}
MiserSpendthrift1程序(参见上文)中的控制流可以描述如下:
- 该程序首先尝试读取并验证一个命令行参数,该参数指定了失败者和储蓄者每次更新帐户余额的次数(例如,一百万次)。
-
main
goroutine通过调用启动另外两个:
这两个启动的goroutine中的第一个代表了失败者 ,第二个代表了节俭 。go func () { // either the miser or the spendthrift
- 该程序使用
sync.WaitGroup
来确保main
goroutine直到sync.WaitGroup
者和节俭的goroutine完成工作并终止后才打印最终余额。
MiserSpendthrift1程序声明了两个全局变量,一个全局变量代表共享的银行帐户,另一个则是互斥体以确保对goroutine的协调访问。
var accountBalance
=
0
// balance for shared bank account
var mutex
= &
sync. Mutex
{}
// mutual-exclusion lock
互斥代码出现在updateBalance
函数中以保护关键部分 ,该部分是必须以单线程方式执行的代码段,程序才能正常运行:
func updateBalance
( amt
int
)
{
mutex
. Lock
()
accountBalance
+= amt
// critical section
mutex
.
Unlock
()
}
关键部分是Lock()
和Unlock()
调用之间的语句。 尽管在Go源代码中只有一行,但是此语句涉及两个不同的操作:算术运算后跟赋值。 这两个操作必须一起执行,一次只能执行一个线程,互斥体代码可以确保此操作。 使用锁定代码后, accountBalance
在末尾为零,因为加1减1的次数相同。
如果删除了互斥代码,则accountBalance
的最终值是不可预测的。 在两次删除了锁码的示例运行中,最终余额在第一次运行中为249,在第二次运行中为-87,从而确认发生了基于内存的竞争情况。
互斥代码的行为值得仔细研究:
- 要执行关键部分代码,goroutine必须首先通过执行
mutex.Lock()
调用来获取锁。 如果已持有该锁,则goroutine会阻塞,直到该锁可用为止;否则,它将继续运行。 否则,goroutine执行互斥保护的关键部分。 - 互斥锁保证相互排斥 ,因为一次只能有一个goroutine可以执行锁定的代码段。 互斥锁确保关键部分的单线程执行:算术运算后跟赋值运算。
- 调用
Unlock()
会释放一个持有的锁,以便某些goroutine(也许刚释放该锁的goroutine)可以重新获取该锁。
在MiserSpendthrift1程序中,三个goroutines(守财奴,spreadthrift和main
)通过名为accountBalance
的共享内存位置进行accountBalance
。 互斥锁协调错误者和挥霍者对这个变量的访问,并且main
仅在错误者和挥霍者都终止之后才尝试访问该变量。 即使使用相对较大的命令行参数(例如,五到一千万),程序accountBalance
运行相对较快,并为accountBalance
产生预期的最终值为零。
包sync/atomic
具有诸如AddInt32
功能,其中AddInt32
了同步。例如,如果accountBalance
类型从int
更改为int32
,则updateBalance
函数可以简化如下:
func updateBalance
( amt
int32
)
{
// argument must be int32 as well
atomic
. AddInt32
( &accountBalance
, amt
)
// no explicit locking required
}
MiserSpendthrift1程序使用显式锁定突出显示关键部分代码,并强调需要进行线程同步以防止出现竞争状况。 在生产级示例中,关键部分可能包含几行源代码。 在任何情况下,关键部分都应尽可能短,以使程序尽可能保持并行。
MiserSpendthrift2
MiserSpendthrift2程序再次将全局变量accountBalance
初始化为零,并且还存在争吵者和节俭的goroutines accountBalance
更新余额。 但是,此程序不使用互斥量来防止出现竞争情况。 取而代之的是,现在有一个银行程序 ,可以响应守财奴和accountBalance
请求访问accountBalance
。 这两个goroutine不再直接更新accountBalance
。 这是体系结构的草图:
requests updates
miser
/ spendthrift
----------
> banker
--------
-> balance
这种体系结构在线程安全的Go通道的支持下可以序列化来自accountBalance
请求,从而可以防止accountBalance
出现竞争情况。
例子2.使用线程安全通道来协调对共享内存的访问
package main
import
(
"os"
"fmt"
"runtime"
"strconv"
"sync"
)
type bankOp
struct
{
// bank operation: deposit or withdraw
howMuch
int
// amount
confirm
chan
int
// confirmation channel
}
var accountBalance
=
0
// shared account
var bankRequests
chan
* bankOp
// channel to banker
func updateBalance
( amt
int
)
int
{
update
:= &bankOp
{ howMuch
: amt
, confirm
:
make
(
chan
int
)}
bankRequests <
- update
newBalance
:= <
- update
. confirm
return newBalance
}
// For now a no-op, but could save balance to a file with a timestamp.
func logBalance
( current
int
)
{
}
func reportAndExit
( msg
string
)
{
fmt
. Println
( msg
)
os
. Exit
(
-
1
)
// all 1s in binary
}
func main
()
{
if
len
( os
. Args
) <
2
{
reportAndExit
(
" \n Usage: go ms1.go <number of updates per thread>"
)
}
iterations
, err
:= strconv
. Atoi
( os
. Args
[
1
])
if err
!=
nil
{
reportAndExit
(
"Bad command-line argument: "
+ os
. Args
[
1
]);
}
bankRequests
=
make
(
chan
* bankOp
,
8
)
// 8 is channel buffer size
var wg sync
. WaitGroup
// The banker: handles all requests for deposits and withdrawals through a channel.
go
func
()
{
for
{
/* The select construct is non-blocking:
-- if there's something to read from a channel, do so
-- otherwise, fall through to the next case, if any */
select
{
case request
:= <
- bankRequests
:
accountBalance
+= request
. howMuch
// update account
request
.
confirm
<- accountBalance
// confirm with current balance
}
}
}()
// miser increments the balance
wg
. Add
(
1
)
// increment WaitGroup counter
go
func
()
{
defer wg
. Done
()
// invoke Done on the WaitGroup when finished
for
i
:=
0
;
i < iterations
;
i
++
{
newBalance
:= updateBalance
(
1
)
logBalance
( newBalance
)
runtime
. Gosched
()
// yield to another goroutine
}
}()
// spendthrift decrements the balance
wg
. Add
(
1
)
// increment WaitGroup counter
go
func
()
{
defer wg
. Done
()
for
i
:=
0
;
i < iterations
;
i
++
{
newBalance
:= updateBalance
(
-
1
)
logBalance
( newBalance
)
runtime
. Gosched
()
// be nice--yield
}
}()
wg
.
Wait
()
// await completion of miser and spendthrift
fmt
.
Println
(
"Final balance: "
, accountBalance
)
// confirm the balance is zero
}
MiserSpendthrift2程序中的更改可以总结如下。 有一个BankOp
结构:
type bankOp
struct
{
// bank operation: deposit or withdraw
howMuch
int
// amount
confirm
chan
int
// confirmation channel
}
苦难者和节俭的goroutine用来发出更新请求。 howMuch
字段是更新量,为1( howMuch
)或-1(节约)。 confirm
字段是银行行长程序用于响应错误或节俭请求的渠道; 该渠道会将新余额返还给请求者作为确认。 为了提高效率, bankOp
结构的地址而不是其副本通过bankRequests
通道发送,该通道声明如下:
var bankRequests chan * bankOp // channel of pointers to a bankOp
默认情况下,通道是同步的(即线程安全的)。
updateBalance
和updateBalance
再次调用updateBalance
函数以更改帐户余额。 此函数不再具有任何显式线程同步:
func updateBalance
( amt
int
)
int
{
// request structure
update
:= &bankOp
{ howMuch
: amt
,
confirm
:
make
(
chan
int
)}
bankRequests <
- update
// send request
newBalance
:=
<- update
.
confirm
// await confirmation
return newBalance
// perhaps to be logged
}
bankRequests
通道的缓冲区大小为八,以最大程度地减少阻塞。 在阻止进一步尝试添加另一个bankOp
指针之前,该通道最多可容纳八个未读请求。 同时,银行程序应在请求到达时对其进行处理。 银行家读取请求后,该请求会自动从渠道中删除。 但是, confirm
通道未缓冲。 请求者将阻止直到确认消息(本地存储在newBalanace
变量中的更新余额)从银行家到达。
局部变量和参数在updateBalance
功能( update
, newBalance
,和amt
)由此线程安全的,因为每一个够程让他们自身的副本。 通道也是线程安全的,因此updateBalance
函数的主体不再需要显式锁定。 程序员真是放心!
银行家goroutine无限循环,等待来自守财奴和挥霍无度的goroutine的请求:
for
{
select
{
case request
:= <
- bankRequests
:
// Is there a request?
accountBalance
+= request
.
howMuch
// If so, update balance and
request
.
confirm
<- accountBalance
// confirm to requester
}
// other cases could be added (e.g., golf outings)
}
当守财奴和节俭的goroutine仍处于活动状态时,只有银行家goroutine可以访问accountBalance
,这意味着不会在此存储位置上出现争用条件。 只有在accountBalance
和accountBalance
完成工作并终止后, main
goroutine才会打印accountBalance
的最终值并退出。 当main
终止时,银行家goroutine也终止。
锁或通道?
MiserSpendthrift2程序遵循Go的口头禅,在同步共享内存上偏爱通道。 可以肯定的是,锁定的内存可能很棘手。 互斥锁API是低级的,因此容易发生诸如锁定但忘记解锁之类的错误-可能导致死锁。 更细微的错误包括仅锁定关键部分的一部分(Unlocking)和锁定不属于关键部分的代码(Overlock)。 线程安全的函数(例如atomic.AddInt32
减少这些风险,因为锁定和解锁会自动发生。 然而,挑战仍然在于如何推理复杂程序中的低级内存锁定。
Go口头禅带来了自己的挑战。 如果两个恶意程序/节俭程序使用足够大的命令行参数运行,则性能上的对比是值得注意的。 互斥锁可能是低级的,但性能良好。 Go频道之所以吸引人,是因为它们提供了内置的线程安全性,并鼓励单线程访问共享的关键资源,例如两个示例程序中的accountBalance
。 但是,与互斥锁相比,通道会导致性能下降。
在编程中很少有一种工具可以满足所有任务。 Go相应地提供了线程安全性选项,从低级锁定到高级通道。
翻译自: https://opensource.com/article/18/7/locks-versus-channels-concurrent-go
go 通道 并发 顺序