# Goroutine多線程同步
Goroutine是Go語言特有的并發(fā)體,是一種輕量級(jí)的線程孟岛,由go關(guān)鍵字啟動(dòng)。在真實(shí)的Go語言的實(shí)現(xiàn)中,goroutine和系統(tǒng)線程也不是等價(jià)的曲稼。盡管兩者的區(qū)別實(shí)際上只是一個(gè)量的區(qū)別索绪,但正是這個(gè)量變引發(fā)了Go語言并發(fā)編程質(zhì)的飛躍。
首先贫悄,每個(gè)系統(tǒng)級(jí)線程都會(huì)有一個(gè)固定大小的棧(一般默認(rèn)可能是2MB)瑞驱,這個(gè)棧主要用來保存函數(shù)遞歸調(diào)用時(shí)參數(shù)和局部變量。固定了棧的大小導(dǎo)致了兩個(gè)問題:一是對(duì)于很多只需要很小的椪梗空間的線程來說是一個(gè)巨大的浪費(fèi)唤反,二是對(duì)于少數(shù)需要巨大棧空間的線程來說又面臨棧溢出的風(fēng)險(xiǎn)鸭津。針對(duì)這兩個(gè)問題的解決方案是:要么降低固定的棧大小彤侍,提升空間的利用率;要么增大棧的大小以允許更深的函數(shù)遞歸調(diào)用逆趋,但這兩者是沒法同時(shí)兼得的盏阶。相反,一個(gè)Goroutine會(huì)以一個(gè)很小的棧啟動(dòng)(可能是2KB或4KB)闻书,當(dāng)遇到深度遞歸導(dǎo)致當(dāng)前椕澹空間不足時(shí),Goroutine會(huì)根據(jù)需要?jiǎng)討B(tài)地伸縮棧的大衅敲肌(主流實(shí)現(xiàn)中棧的最大值可達(dá)到1GB)砰盐。因?yàn)閱?dòng)的代價(jià)很小,所以我們可以輕易地啟動(dòng)成千上萬個(gè)Goroutine坑律。
Go的運(yùn)行時(shí)還包含了其自己的調(diào)度器楞卡,這個(gè)調(diào)度器使用了一些技術(shù)手段,可以在n個(gè)操作系統(tǒng)線程上多工調(diào)度m個(gè)Goroutine脾歇。Go調(diào)度器的工作和內(nèi)核的調(diào)度是相似的蒋腮,但是這個(gè)調(diào)度器只關(guān)注單獨(dú)的Go程序中的Goroutine。Goroutine采用的是半搶占式的協(xié)作調(diào)度藕各,只有在當(dāng)前Goroutine發(fā)生阻塞時(shí)才會(huì)導(dǎo)致調(diào)度池摧;同時(shí)發(fā)生在用戶態(tài),調(diào)度器會(huì)根據(jù)具體函數(shù)只保存必要的寄存器激况,切換的代價(jià)要比系統(tǒng)線程低得多作彤。運(yùn)行時(shí)有一個(gè)runtime.GOMAXPROCS變量,用于控制當(dāng)前運(yùn)行正常非阻塞Goroutine的系統(tǒng)線程數(shù)目乌逐。
在main.main函數(shù)執(zhí)行之前所有代碼都運(yùn)行在同一個(gè)goroutine竭讳,也就是程序的主系統(tǒng)線程中。因此浙踢,如果某個(gè)init函數(shù)內(nèi)部用go關(guān)鍵字啟動(dòng)了新的goroutine的話绢慢,新的goroutine只有在進(jìn)入main.main函數(shù)之后才可能被執(zhí)行到。
## 基于原子操作的同步
所謂的原子操作就是并發(fā)編程中“最小的且不可并行化”的操作洛波。通常胰舆,如果多個(gè)并發(fā)體對(duì)同一個(gè)共享資源進(jìn)行的操作是原子的話骚露,那么同一時(shí)刻最多只能有一個(gè)并發(fā)體對(duì)該資源進(jìn)行操作。從線程角度看缚窿,在當(dāng)前線程修改共享資源期間棘幸,其它的線程是不能訪問該資源的。原子操作對(duì)于多線程并發(fā)編程模型來說倦零,不會(huì)發(fā)生有別于單線程的意外情況误续,共享資源的完整性可以得到保證。一般情況下扫茅,原子操作都是通過“互斥”訪問來保證的蹋嵌,通常由特殊的CPU指令提供保護(hù)。當(dāng)然诞帐,如果僅僅是想模擬下粗粒度的原子操作欣尼,我們可以借助于sync.Mutex來實(shí)現(xiàn):
```Go
import (
? ? ? ? "sync"
? ? ? ? )
var total struct {
? ? ? ? sync.Mutex
? ? ? ? value int
}
func worker(wg *sync.WaitGroup) {
? ? ? ? defer wg.Done()
? ? ? ? for i := 0; i <= 100; i++ {
? ? ? ? ? ? ? ? total.Lock()
? ? ? ? ? ? ? ? total.value += i
? ? ? ? ? ? ? ? total.Unlock()
? ? ? ? }}
func main() {
? ? ? ? var wg sync.WaitGroup
? ? ? ? wg.Add(2)
? ? ? ? go worker(&wg)
? ? ? ? go worker(&wg)
? ? ? ? wg.Wait()
? ? ? ? fmt.Println(total.value)}
```
在worker的循環(huán)中爆雹,為了保證total.value += i的原子性停蕉,我們通過sync.Mutex加鎖和解鎖來保證該語句在同一時(shí)刻只被一個(gè)線程訪問。對(duì)于多線程模型的程序而言钙态,進(jìn)出臨界區(qū)前后進(jìn)行加鎖和解鎖都是必須的慧起。如果沒有鎖的保護(hù),total的最終值將由于多線程之間的競爭而可能會(huì)不正確册倒。
用互斥鎖來保護(hù)一個(gè)數(shù)值型的共享資源蚓挤,麻煩且效率低下。標(biāo)準(zhǔn)庫的sync/atomic包對(duì)原子操作提供了豐富的支持驻子。我們可以重新實(shí)現(xiàn)上面的例子:
```Go
import (
? ? ? ? "sync"
? ? ? ? "sync/atomic")
var total uint64
func worker(wg *sync.WaitGroup) {
? ? ? ? defer wg.Done()
? ? ? ? var i uint64
? ? ? ? for i = 0; i <= 100; i++ {
? ? ? ? ? ? ? ? atomic.AddUint64(&total, i)
? ? ? ? }}
func main() {
? ? ? ? var wg sync.WaitGroup
? ? ? ? wg.Add(2)
? ? ? ? go worker(&wg)
? ? ? ? go worker(&wg)
? ? ? ? wg.Wait()
}
```
atomic.AddUint64函數(shù)調(diào)用保證了total的讀取灿意、更新和保存是一個(gè)原子操作,因此在多線程中訪問也是安全的崇呵。原子操作配合互斥鎖可以實(shí)現(xiàn)非常高效的單件模式缤剧。互斥鎖的代價(jià)比普通整數(shù)的原子讀寫高很多域慷,在性能敏感的地方可以增加一個(gè)數(shù)字型的標(biāo)志位荒辕,通過原子檢測標(biāo)志位狀態(tài)降低互斥鎖的使用次數(shù)來提高性能。
### 單例模式
基于sync.Once實(shí)現(xiàn)單例模式
```Go
? ? ? ? m? ? Mutex
? ? ? ? done uint32
}
func (o *Once) Do(f func()) {
? ? ? ? if atomic.LoadUint32(&o.done) == 1 {
? ? ? ? ? ? ? ? return
? ? ? ? }
? ? ? ? o.m.Lock()
? ? ? ? defer o.m.Unlock()
? ? ? ? if o.done == 0 {
? ? ? ? ? ? ? ? defer atomic.StoreUint32(&o.done, 1)
? ? ? ? ? ? ? ? f()
? ? ? ? }
}
```
sync/atomic包對(duì)基本的數(shù)值類型及復(fù)雜對(duì)象的讀寫都提供了原子操作的支持犹褒。atomic.Value原子對(duì)象提供了Load和Store兩個(gè)原子方法抵窒,分別用于加載和保存數(shù)據(jù),返回值和參數(shù)都是interface{}類型叠骑,因此可以用于任意的自定義復(fù)雜類型李皇。
```Go
var config atomic.Value // 保存當(dāng)前配置信息// 初始化配置信息
config.Store(loadConfig())// 啟動(dòng)一個(gè)后臺(tái)線程, 加載更新后的配置信息
go func() {
? ? ? ? for {
? ? ? ? ? ? ? ? time.Sleep(time.Second)
? ? ? ? ? ? ? ? config.Store(loadConfig())
? ? ? ? }}()// 用于處理請(qǐng)求的工作者線程始終采用最新的配置信息
? ? ? ? for i := 0; i < 10; i++ {
? ? ? ? ? ? ? ? go func() {
? ? ? ? ? ? ? ? ? ? ? ? for r := range requests() {
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? c := config.Load()
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? // ...
? ? ? ? ? ? ? ? ? ? ? ? }
? ? ? ? ? ? ? ? }()
}
```
## 基于Channel的同步
Channel通信是在Goroutine之間進(jìn)行同步的主要方法。在無緩存的Channel上的每一次發(fā)送操作都有與其對(duì)應(yīng)的接收操作相配對(duì)宙枷,發(fā)送和接收操作通常發(fā)生在不同的Goroutine上(在同一個(gè)Goroutine上執(zhí)行2個(gè)操作很容易導(dǎo)致死鎖)疙赠。無緩存的Channel上的發(fā)送操作總在對(duì)應(yīng)的接收操作完成前發(fā)生.
```Go
var done = make(chan bool)
var msg string
func aGoroutine() {
? ? ? ? msg = "你好, 世界"
? ? ? ? done <- true
}
func main() {
? ? ? ? go aGoroutine()
? ? ? ? <-done
? ? ? ? println(msg)
}
```
對(duì)于這個(gè)程序雖然goroutine和main沒有嚴(yán)格的前后關(guān)系付材,但是由于done channel的返送需要發(fā)生在接收之后,這樣就能保證主線程不會(huì)在go沒有執(zhí)行完成之前結(jié)束圃阳⊙嵯危可保證打印出“hello, world”。該程序首先對(duì)msg進(jìn)行寫入捍岳,然后在done管道上發(fā)送同步信號(hào)富寿,隨后從done接收對(duì)應(yīng)的同步信號(hào),最后執(zhí)行println函數(shù)锣夹。若在關(guān)閉Channel后繼續(xù)從中接收數(shù)據(jù)页徐,接收者就會(huì)收到該Channel返回的零值。因此在這個(gè)例子中银萍,用close(c)關(guān)閉管道代替done <- false依然能保證該程序產(chǎn)生相同的行為变勇。但是對(duì)已經(jīng)關(guān)閉的channel發(fā)送數(shù)據(jù)會(huì)導(dǎo)致程序panic,這里建議不關(guān)閉贴唇,channel也可以實(shí)現(xiàn)不同goroutine之間的處理搀绣。
對(duì)于帶緩沖的Channel,對(duì)于Channel的第K個(gè)接收完成操作發(fā)生在第K+C個(gè)發(fā)送操作完成之前戳气,其中C是Channel的緩存大小链患。 如果將C設(shè)置為0自然就對(duì)應(yīng)無緩存的Channel,也即使第K個(gè)接收完成在第K個(gè)發(fā)送完成之前瓶您。因?yàn)闊o緩存的Channel只能同步發(fā)1個(gè)麻捻,也就簡化為前面無緩存Channel的規(guī)則:對(duì)于從無緩沖Channel進(jìn)行的接收,發(fā)生在對(duì)該Channel進(jìn)行的發(fā)送完成之前呀袱。我們可以根據(jù)控制Channel的緩存大小來控制并發(fā)執(zhí)行的Goroutine的最大數(shù)目:
```Go
var work = []func(){
? ? ? ? func() { println("1"); time.Sleep(1 * time.Second) },
? ? ? ? func() { println("2"); time.Sleep(1 * time.Second) },
? ? ? ? func() { println("3"); time.Sleep(1 * time.Second) },
? ? ? ? func() { println("4"); time.Sleep(1 * time.Second) },
? ? ? ? func() { println("5"); time.Sleep(1 * time.Second) },
}
func main() {
? ? ? ? for _, w := range work {
? ? ? ? ? ? ? ? go func(w func()) {
? ? ? ? ? ? ? ? ? ? ? ? limit <- 1
? ? ? ? ? ? ? ? ? ? ? ? w()
? ? ? ? ? ? ? ? ? ? ? ? <-limit
? ? ? ? ? ? ? ? }(w)
? ? ? ? }
? ? ? ? select{}
}
```
在循環(huán)創(chuàng)建Goroutine過程中贸毕,使用了匿名函數(shù)并在函數(shù)中引用了循環(huán)變量w,由于w是引用傳遞的而非值傳遞明棍,因此無法保證Goroutine在運(yùn)行時(shí)調(diào)用的w與循環(huán)創(chuàng)建時(shí)的w是同一個(gè)值油吭,為了解決這個(gè)問題,我們可以利用函數(shù)傳參的值復(fù)制來為每個(gè)Goroutine單獨(dú)復(fù)制一份w婉宰。
同樣的也可以同時(shí)10個(gè)后臺(tái)線程分別打痈璨颉:
```Go
func main() {
? ? ? ? done := make(chan int, 10) // 帶 10 個(gè)緩存
? ? ? ? // 開N個(gè)后臺(tái)打印線程
? ? ? ? for i := 0; i < cap(done); i++ {
? ? ? ? ? ? ? ? go func(){
? ? ? ? ? ? ? ? ? ? ? ? fmt.Println("你好, 世界")
? ? ? ? ? ? ? ? ? ? ? ? done <- 1
? ? ? ? ? ? ? ? }()
? ? ? ? }
? ? ? ? // 等待N個(gè)后臺(tái)線程完成
? ? ? ? for i := 0; i < cap(done); i++ {
? ? ? ? ? ? ? ? <-done
? ? ? ? }
}
```
對(duì)于這種要等待N個(gè)線程完成后再進(jìn)行下一步的同步操作有一個(gè)簡單的做法心包,就是使用sync.WaitGroup來等待一組事件:
```Go
func main() {
? ? ? ? var wg sync.WaitGroup
? ? ? ? // 開N個(gè)后臺(tái)打印線程
? ? ? ? for i := 0; i < 10; i++ {
? ? ? ? ? ? ? ? wg.Add(1)
? ? ? ? ? ? ? ? go func() {
? ? ? ? ? ? ? ? ? ? ? ? fmt.Println("你好, 世界")
? ? ? ? ? ? ? ? ? ? ? ? wg.Done()
? ? ? ? ? ? ? ? }()
? ? ? ? }
? ? ? ? // 等待N個(gè)后臺(tái)線程完成
? ? ? ? wg.Wait()
}
```
中wg.Add(1)用于增加等待事件的個(gè)數(shù),必須確保在后臺(tái)線程啟動(dòng)之前執(zhí)行(如果放到后臺(tái)線程之中執(zhí)行則不能保證被正常執(zhí)行到)。當(dāng)后臺(tái)線程完成打印工作之后痕惋,調(diào)用wg.Done()表示完成一個(gè)事件区宇。main函數(shù)的wg.Wait()是等待全部的事件完成。
循環(huán)創(chuàng)建結(jié)束后议谷,在main函數(shù)中最后一句select{}是一個(gè)空的管道選擇語句堕虹,該語句會(huì)導(dǎo)致main線程阻塞赴捞,從而避免程序過早退出。還有for{}赦政、<-make(chan int)等諸多方法可以達(dá)到類似的效果。因?yàn)閙ain線程被阻塞了桐愉,如果需要程序正常退出的話可以通過調(diào)用os.Exit(0)實(shí)現(xiàn)仅财。
### 生產(chǎn)者消費(fèi)者模型
并發(fā)編程最常見的例子就是生產(chǎn)者消費(fèi)者模式碗淌,該模式主要通過生產(chǎn)和消費(fèi)的能力來提高程序的整體處理數(shù)據(jù)的速度抖锥,簡單來說就是生成數(shù)據(jù)磅废,然后放到成果隊(duì)列中,消費(fèi)者從成果隊(duì)列中獲得數(shù)據(jù)竟趾,這樣將一個(gè)東西拆分解耦成為前置條件的產(chǎn)生和后置消費(fèi)的異步行為宫峦。當(dāng)成果隊(duì)列中沒有數(shù)據(jù)時(shí)导绷,消費(fèi)者就進(jìn)入饑餓的等待中;而當(dāng)成果隊(duì)列中數(shù)據(jù)已滿時(shí),生產(chǎn)者則面臨因產(chǎn)品擠壓導(dǎo)致CPU被剝奪的下崗問題钦购。
Go語言實(shí)現(xiàn)生產(chǎn)者消費(fèi)者并發(fā)很簡單:
```Go
// 生產(chǎn)者: 生成 factor 整數(shù)倍的序列
func Producer(factor int, out chan<- int) {
? ? ? ? for i := 0; ; i++ {
? ? ? ? ? ? ? ? out <- i*factor
? ? ? ? }
}// 消費(fèi)者
func Consumer(in <-chan int) {
? ? ? ? for v := range in {
? ? ? ? ? ? ? ? fmt.Println(v)
? ? ? ? }
}
func main() {
? ? ? ? ch := make(chan int, 64) // 成果隊(duì)列
? ? ? ? go Producer(3, ch) // 生成 3 的倍數(shù)的序列
? ? ? ? go Producer(5, ch) // 生成 5 的倍數(shù)的序列
? ? ? ? go Consumer(ch)? ? // 消費(fèi) 生成的隊(duì)列
? ? ? ? // 運(yùn)行一定時(shí)間后退出
? ? ? ? sig := make(chan os.Signal, 1)
? ? ? ? signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
? ? ? ? fmt.Printf("quit (%v)\n", <-sig)
}
```
我們開啟了2個(gè)Producer生產(chǎn)流水線押桃,分別用于生成3和5的倍數(shù)的序列导犹。然后開啟1個(gè)Consumer消費(fèi)者線程锡足,打印獲取的結(jié)果。我們可以讓main函數(shù)保存阻塞狀態(tài)不退出掰烟,只有當(dāng)用戶輸入Ctrl-C時(shí)才真正退出程序.我們這個(gè)例子中有2個(gè)生產(chǎn)者沐批,并且2個(gè)生產(chǎn)者之間并無同步事件可參考九孩,它們是并發(fā)的。因此煤墙,消費(fèi)者輸出的結(jié)果序列的順序是不確定的宪拥,這并沒有問題,生產(chǎn)者和消費(fèi)者依然可以相互配合工作脚作。
## 觀察者模式
觀察者模式也可以叫做發(fā)布訂閱模式缔刹,在對(duì)象間定義一對(duì)多的依賴關(guān)系校镐,且需要一個(gè)對(duì)象變化的時(shí)候多個(gè)對(duì)象做出相應(yīng)的響應(yīng)則可以使用觀察者模式。對(duì)于被觀察者來說魏烫,他并不關(guān)心誰觀察了自己,只需要發(fā)布自己發(fā)生了變化就可以了稀蟋,這也是觀察者的好處退客,不變的對(duì)象作為被觀察者链嘀,變的對(duì)象作為觀察者,使用注冊(cè)增加或者減少觀察者茫藏,但是對(duì)于被觀察者沒有影響霹琼。
```Go
// 定義消息類型
type MSG string
type ISubject interface {
? Registry(observer IObserver)? ? ? ? // 注冊(cè)觀察
? Remove(observer IObserver)? ? ? ? ? // 取消觀察
? Notify(msg MSG)? ? ? ? ? ? ? ? ? ? // 通知觀察者
}
type IObserver interface {
? Update(msg MSG)? ? ? ? ? ? ? ? ? ? // 當(dāng)觀察對(duì)象發(fā)生變化的時(shí)候進(jìn)行響應(yīng)
? //GetName() string
}
```
具體實(shí)現(xiàn)可以參考如下:
```Go
package main
import "log"
type MSG string //這里簡單使用string作為msg實(shí)際上可以更加復(fù)雜
type ISubject interface {
? Registry(observer IObserver)
? Remove(observer IObserver)
? Notify(msg MSG)
}
type IObserver interface {
? Update(msg MSG)
? GetName() string // getName 不是必須但是為了方便我選擇使用map保存隊(duì)列
}
type Subject struct {
? Observers map[string]IObserver
}
func NewSubject() Subject {
? return Subject{
? ? ? Observers: make(map[string]IObserver),
? }
}
func (s *Subject) Registry(observer IObserver) {
? s.Observers[observer.GetName()] = observer
}
func (s *Subject) Remove(observer IObserver) {
? delete(s.Observers, observer.GetName())
}
func (s *Subject) Notify(msg MSG) {
? for _, v := range s.Observers {
? ? ? v.Update(msg)
? }
}
type Observer struct {
? Name string
}
func (o *Observer) Update(msg MSG) {
? log.Print(o.GetName(), ":")
? log.Printf(
? ? ? "%s", msg)
}
func (o *Observer) GetName() string {
? return o.Name
}
func main() {
? subject := NewSubject()
? o1 := Observer{Name: "observer 1"}
? o2 := Observer{Name: "observer 2"}
? subject.Registry(&o1)
? subject.Registry(&o2)
? subject.Notify("發(fā)生什么事啦售葡!")
}
```
這種方式會(huì)持有對(duì)象忠藤,很多時(shí)候其實(shí)不需要持有對(duì)象模孩,可以使用函數(shù)作為參數(shù)輸入進(jìn)行調(diào)用,將Update作為函數(shù)的call函數(shù)進(jìn)行調(diào)用诺祸,當(dāng)數(shù)據(jù)發(fā)生變化進(jìn)行call調(diào)用祭芦。
更加復(fù)雜的將通知這件事情作為生產(chǎn)內(nèi)容龟劲,將調(diào)用作為消費(fèi)手段實(shí)現(xiàn)異步調(diào)用轴或,就可以實(shí)現(xiàn)異步調(diào)用:
```Go
/**
* @Author:Dijiang
* @Description:
* @Date: Created in 11:25 2022/4/6
* @Modified By: Dijiang
*/
package main
import (
? "fmt"
? "log"
? "os"
? "os/signal"
? "syscall"
)
type MSG string //這里簡單使用string作為msg實(shí)際上可以更加復(fù)雜
type IObserver func()
var msgChan = make(chan IObserver, 5)
type ISubject interface {
? Registry(observer IObserver)
? Remove(observer IObserver)
? Notify(msg MSG)
}
type Subject struct {
? Observers []IObserver
? msgChan? chan func(MSG)
}
func NewSubject() Subject {
? return Subject{
? ? ? Observers: make([]IObserver, 0),
? }
}
func (s *Subject) Registry(observer IObserver) {
? s.Observers = append(s.Observers, observer)
}
func (s *Subject) Remove(observer IObserver) {
? //for i := 0; i < len(s.Observers); i++ {
? // if s.Observers[i] == observer {
? //? ? s.Observers = append(s.Observers[:i], s.Observers[i+1:]...)
? //? ? return
? // }
? //}
? log.Printf("can't find observer %v", observer)
}
func (s *Subject) Notify(msg MSG) {
? for _, v := range s.Observers {
? ? ? msgChan <- v
? }
? //println(len(msgChan))
}
type Observer struct {
? Name string
}
func (o *Observer) GetName() string {
? return o.Name
}
func main() {
? subject := NewSubject()
? o1 := func() {
? ? ? println("01:")
? }
? o2 := func() {
? ? ? println("02: ")
? }
? subject.Registry(o1)
? subject.Registry(o2)
? go func() {
? ? ? var v IObserver
? ? ? for {
? ? ? ? select {
? ? ? ? case v = <-msgChan:
? ? ? ? ? ? v()
? ? ? ? default:
? ? ? ? ? ? continue
? ? ? ? }
? ? ? }
? }()
? subject.Notify("發(fā)生什么事啦蚕愤!")
? subject.Notify("發(fā)生什么事啦!")
? subject.Notify("發(fā)生什么事啦悬嗓!")
? subject.Notify("發(fā)生什么事啦裕坊!")
? subject.Notify("發(fā)生什么事啦!")
? subject.Notify("發(fā)生什么事啦周瞎!")
? // 運(yùn)行一定時(shí)間后退出
? sig := make(chan os.Signal, 1)
? signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
? fmt.Printf("quit (%v)\n", <-sig)
}
```
這樣的簡單改造可以使得觀察者模式更加易用声诸。同時(shí)需要注意到很多的mvvm模式都可以說是這種的觀察者模式退盯,如vue和依賴定時(shí)刷新界面的mvvm模式。(但是個(gè)人感覺而言囤攀,mvvm難點(diǎn)在于數(shù)據(jù)劫持和樹狀依賴的構(gòu)建而不在于觀察者模式)
![](undefined)