Go Channel 应用模式
2019独角兽企业重金招聘Python工程师标准>>>
目录 [−]
- Lock/TryLock 模式
- Hacked Lock/TryLock 模式
- TryLock By Channel
- TryLock with Timeout
- Or Channel 模式
- Goroutine方式
- Reflect方式
- 递归方式
- Or-Done-Channel模式
- 扇入模式
- Goroutine方式
- Reflect
- 递归方式
- Tee模式
- Goroutine方式
- Reflect方式
- 分布模式
- Goroutine方式
- Reflect方式
- eapache
- Distribute
- Tee
- Multiplex
- Pipe
- 集合操作
- skip
- skipN
- skipFn
- skipWhile
- take
- takeN
- takeFn
- takeWhile
- flat
- map
- reduce
- skip
- 总结
- 参考资料
Channel是Go中的一种类型,和goroutine一起为Go提供了并发技术, 它在开发中得到了广泛的应用。Go鼓励人们通过Channel在goroutine之间传递数据的引用(就像把数据的owner从一个goroutine传递给另外一个goroutine), Effective Go总结了这么一句话:
Do not communicate by sharing memory; instead, share memory by communicating.
在 Go内存模型指出了channel作为并发控制的一个特性:
A send on a channel happens before the corresponding receive from that channel completes. (Golang Spec)
除了正常的在goroutine之间安全地传递共享数据, Channel还可以玩出很多的花样(模式), 本文列举了一些channel的应用模式。
促成本文诞生的因素主要包括:
- eapache的channels库
- concurrency in go 这本书
- Francesc Campoy的 justforfun系列中关于merge channel的实现
- 我在出版Scala集合手册这本书中对Scala集合的启发
下面就让我们以实例的方式看看这么模式吧。
Lock/TryLock 模式
我们知道, Go的标准库sync
有Mutex
,可以用来作为锁,但是Mutex
却没有实现TryLock
方法。
我们对于TryLock
的定义是当前goroutine尝试获得锁, 如果成功,则获得了锁,返回true, 否则返回false。我们可以使用这个方法避免在获取锁的时候当前goroutine被阻塞住。
本来,这是一个常用的功能,在一些其它编程语言中都有实现,为什么Go中没有实现的?issue#6123有详细的讨论,在我看来,Go核心组成员本身对这个特性没有积极性,并且认为通过channel可以实现相同的方式。
1/ Hacked Lock/TryLock 模式
其实,对于标准库的sync.Mutex
要增加这个功能很简单,下面的方式就是通过hack
的方式为Mutex
实现了TryLock
的功能。
const mutexLocked = 1 << iota
type Mutex struct {mu sync.Mutex
}
func (m *Mutex) Lock() {m.mu.Lock()
}
func (m *Mutex) Unlock() {m.mu.Unlock()
}
func (m *Mutex) TryLock() bool {return atomic.CompareAndSwapInt32((*int32)(unsafe.Pointer(&m.mu)), 0, mutexLocked)
}
func (m *Mutex) IsLocked() bool {return atomic.LoadInt32((*int32)(unsafe.Pointer(&m.mu))) == mutexLocked
}
上面的代码还额外增加了一个IsLocked
方法,不过这个方法一般不常用,因为查询和加锁这两个方法执行的时候不是一个原子的操作,素以这个方法一般在调试和打日志的时候可能有用。如果你看一下Mutex
实现的源代码,就很容易理解上面的这段代码了,因为mutex
实现锁主要利用CAS
对它的一个int32字段做操作。
2/ TryLock By Channel
既然标准库中不准备在Mutex
上增加这个方法,而是推荐使用channel来实现,那么就让我们看看如何使用 channel来实现。
type Mutex struct {ch chan struct{}
}
func NewMutex() *Mutex {mu := &Mutex{make(chan struct{}, 1)}mu.ch <- struct{}{}return mu
}
func (m *Mutex) Lock() {<-m.ch
}
func (m *Mutex) Unlock() {select {case m.ch <- struct{}{}:default:panic("unlock of unlocked mutex")}
}
func (m *Mutex) TryLock() bool {select {case <-m.ch:return truedefault:}return false
}
func (m *Mutex) IsLocked() bool {return len(m.ch) > 0
}
你还可以将缓存的大小从1改为n,用来处理n个锁(资源)。主要是利用channel边界情况下的阻塞特性实现的。
3/ TryLock with Timeout
有时候,我们在获取一把锁的时候,由于有竞争的关系,在锁被别的goroutine拥有的时候,当前goroutine没有办法立即获得锁,只能阻塞等待。标准库并没有提供等待超时的功能,我们尝试实现它。
type Mutex struct {ch chan struct{}
}
func NewMutex() *Mutex {mu := &Mutex{make(chan struct{}, 1)}mu.ch <- struct{}{}return mu
}
func (m *Mutex) Lock() {<-m.ch
}
func (m *Mutex) Unlock() {select {case m.ch <- struct{}{}:default:panic("unlock of unlocked mutex")}
}
func (m *Mutex) TryLock(timeout time.Duration) bool {timer := time.NewTimer(timeout)select {case <-m.ch:timer.Stop()return truecase <-time.After(timeout):}return false
}
func (m *Mutex) IsLocked() bool {return len(m.ch) > 0
}
Or Channel 模式
你也可以把它用Context
来改造,不是利用超时,而是利用Context
来取消/超时获得锁的操作,这个作业留给读者来实现。
当你等待多个信号的时候,如果收到任意一个信号, 就执行业务逻辑,忽略其它的还未收到的信号。
举个例子, 我们往提供相同服务的n个节点发送请求,只要任意一个服务节点返回结果,我们就可以执行下面的业务逻辑,其它n-1的节点的请求可以被取消或者忽略。当n=2的时候,这就是back request
模式。 这样可以用资源来换取latency的提升。
需要注意的是,当收到任意一个信号的时候,其它信号都被忽略。如果用channel来实现,只要从任意一个channel中接收到一个数据,那么所有的channel都可以被关闭了(依照你的实现,但是输出的channel肯定会被关闭)。
有三种实现的方式: goroutine、reflect和递归。
1/ Goroutine方式
func or(chans ...<-chan interface{}) <-chan interface{} {out := make(chan interface{})go func() {var once sync.Oncefor _, c := range chans {go func(c <-chan interface{}) {select {case <-c:once.Do(func() { close(out) })case <-out:}}(c)}}()return out
}
为了避免并发关闭输出channel的问题,关闭操作只执行一次。or
函数可以处理n个channel,它为每个channel启动一个goroutine,只要任意一个goroutine从channel读取到数据,输出的channel就被关闭掉了。
2/ Reflect方式
Go的反射库针对select语句有专门的数据(reflect.SelectCase
)和函数(reflect.Select
)处理。
所以我们可以利用反射“随机”地从一组可选的channel中接收数据,并关闭输出channel。
这种方式看起来更简洁。
func or(channels ...<-chan interface{}) <-chan interface{} {switch len(channels) {case 0:return nilcase 1:return channels[0]}orDone := make(chan interface{})go func() {defer close(orDone)var cases []reflect.SelectCasefor _, c := range channels {cases = append(cases, reflect.SelectCase{Dir: reflect.SelectRecv,Chan: reflect.ValueOf(c),})}reflect.Select(cases)}()return orDone
}
3/ 递归方式
递归方式一向是比较开脑洞的实现,下面的方式就是分而治之的方式,逐步合并channel,最终返回一个channel。
func or(channels ...<-chan interface{}) <-chan interface{} {switch len(channels) {case 0:return nilcase 1:return channels[0]}orDone := make(chan interface{})go func() {defer close(orDone)switch len(channels) {case 2:select {case <-channels[0]:case <-channels[1]:}default:m := len(channels) / 2select {case <-or(channels[:m]...):case <-or(channels[m:]...):}}}()return orDone
}
Or-Done-Channel模式
在后面的扇入(合并)模式中,我们还是会使用相同样的递归模式来合并多个输入channel,根据 justforfun 的测试结果,这种递归的方式要比goroutine、Reflect更有效。
这种模式是我们经常使用的一种模式,通过一个信号channel(done)来控制(取消)输入channel的处理。
一旦从done channel中读取到一个信号,或者done channel被关闭, 输入channel的处理则被取消。
这个模式提供一个简便的方法,把done channel 和 输入 channel 融合成一个输出channel。
func orDone(done <-chan struct{}, c <-chan interface{}) <-chan interface{} {valStream := make(chan interface{})go func() {defer close(valStream)for {select {case <-done:returncase v, ok := <-c:if ok == false {return}select {case valStream <- v:case <-done:}}}}()return valStream
}
扇入模式
扇入模式(FanIn)是将多个同样类型的输入channel合并成一个同样类型的输出channel,也就是channel的合并。
1/ Goroutine方式
每个channel起一个goroutine。
func fanIn(chans ...<-chan interface{}) <-chan interface{} {out := make(chan interface{})go func() {var wg sync.WaitGroupwg.Add(len(chans))for _, c := range chans {go func(c <-chan interface{}) {for v := range c {out <- v}wg.Done()}(c)}wg.Wait()close(out)}()return out
2/ Reflect
利用反射库针对select语句的处理合并输入channel。
下面这种实现方式其实还是有些问题的, 在输入channel读取比较均匀的时候比较有效,否则性能比较低下。
func fanInReflect(chans ...<-chan interface{}) <-chan interface{} {out := make(chan interface{})go func() {defer close(out)var cases []reflect.SelectCasefor _, c := range chans {cases = append(cases, reflect.SelectCase{Dir: reflect.SelectRecv,Chan: reflect.ValueOf(c),})}for len(cases) > 0 {i, v, ok := reflect.Select(cases)if !ok { //remove this casecases = append(cases[:i], cases[i+1:]...)continue}out <- v.Interface()}}()return out
}
3/ 递归方式
这种方式虽然理解起来不直观,但是性能还是不错的(输入channel不是很多的情况下递归层级不会很高,不会成为瓶颈)
func fanInRec(chans ...<-chan interface{}) <-chan interface{} {switch len(chans) {case 0:c := make(chan interface{})close(c)return ccase 1:return chans[0]case 2:return mergeTwo(chans[0], chans[1])default:m := len(chans) / 2return mergeTwo(fanInRec(chans[:m]...),fanInRec(chans[m:]...))}
}
func mergeTwo(a, b <-chan interface{}) <-chan interface{} {c := make(chan interface{})go func() {defer close(c)for a != nil || b != nil {select {case v, ok := <-a:if !ok {a = nilcontinue}c <- vcase v, ok := <-b:if !ok {b = nilcontinue}c <- v}}}()return c
}
Tee模式
扇出模式(FanOut)是将一个输入channel扇出为多个channel。
扇出行为至少可以分为两种:
- 从输入channel中读取一个数据,发送给每个输入channel,这种模式称之为Tee模式
- 从输入channel中读取一个数据,在输出channel中选择一个channel发送
本节只介绍第一种情况,下一节介绍第二种情况
1/ Goroutine方式
将读取的值发送给每个输出channel, 异步模式可能会产生很多的goroutine。
func fanOut(ch <-chan interface{}, out []chan interface{}, async bool) {go func() {defer func() {for i := 0; i < len(out); i++ {close(out[i])}}()for v := range ch {v := vfor i := 0; i < len(out); i++ {i := iif async {go func() {out[i] <- v}()} else {out[i] <- v}}}}()
}
2/ Reflect方式
这种模式一旦一个输出channel被阻塞,可能会导致后续的处理延迟。
func fanOutReflect(ch <-chan interface{}, out []chan interface{}) {go func() {defer func() {for i := 0; i < len(out); i++ {close(out[i])}}()cases := make([]reflect.SelectCase, len(out))for i := range cases {cases[i].Dir = reflect.SelectSend}for v := range ch {v := vfor i := range cases {cases[i].Chan = reflect.ValueOf(out[i])cases[i].Send = reflect.ValueOf(v)}for _ = range cases { // for each channelchosen, _, _ := reflect.Select(cases)cases[chosen].Chan = reflect.ValueOf(nil)}}}()
}
分布模式
分布模式将从输入channel中读取的值往输出channel中的其中一个发送。
1/ Goroutine方式
roundrobin的方式选择输出channel。
func fanOut(ch <-chan interface{}, out []chan interface{}) {go func() {defer func() {for i := 0; i < len(out); i++ {close(out[i])}}()// roundrobinvar i = 0var n = len(out)for v := range ch {v := vout[i] <- vi = (i + 1) % n}}()
}
2/ Reflect方式
利用发射随机的选择。
func fanOutReflect(ch <-chan interface{}, out []chan interface{}) {go func() {defer func() {for i := 0; i < len(out); i++ {close(out[i])}}()cases := make([]reflect.SelectCase, len(out))for i := range cases {cases[i].Dir = reflect.SelectSendcases[i].Chan = reflect.ValueOf(out[i])}for v := range ch {v := vfor i := range cases {cases[i].Send = reflect.ValueOf(v)}_, _, _ = reflect.Select(cases)}}()
}
eapache
eapache/channels提供了一些channel应用模式的方法,比如上面的扇入扇出模式等。
因为go本身的channel无法再进行扩展, eapache/channels
库定义了自己的channel接口,并提供了与channel方便的转换。
eapache/channels
提供了四个方法:
- Distribute: 从输入channel读取值,发送到其中一个输出channel中。当输入channel关闭后,输出channel都被关闭
- Tee: 从输入channel读取值,发送到所有的输出channel中。当输入channel关闭后,输出channel都被关闭
- Multiplex: 合并输入channel为一个输出channel, 当所有的输入都关闭后,输出才关闭
- Pipe: 将两个channel串起来
同时对上面的四个函数还提供了WeakXXX
的函数,输入关闭后不会关闭输出。
下面看看对应的函数的例子。
1/ Distribute
func testDist() {fmt.Println("dist:")a := channels.NewNativeChannel(channels.None)outputs := []channels.Channel{channels.NewNativeChannel(channels.None),channels.NewNativeChannel(channels.None),channels.NewNativeChannel(channels.None),channels.NewNativeChannel(channels.None),}channels.Distribute(a, outputs[0], outputs[1], outputs[2], outputs[3])//channels.WeakDistribute(a, outputs[0], outputs[1], outputs[2], outputs[3])go func() {for i := 0; i < 5; i++ {a.In() <- i}a.Close()}()for i := 0; i < 6; i++ {var v interface{}var j intselect {case v = <-outputs[0].Out():j = 0case v = <-outputs[1].Out():j = 1case v = <-outputs[2].Out():j = 2case v = <-outputs[3].Out():j = 3}fmt.Printf("channel#%d: %d\n", j, v)}
}
2/ Tee
func testTee() {fmt.Println("tee:")a := channels.NewNativeChannel(channels.None)outputs := []channels.Channel{channels.NewNativeChannel(channels.None),channels.NewNativeChannel(channels.None),channels.NewNativeChannel(channels.None),channels.NewNativeChannel(channels.None),}channels.Tee(a, outputs[0], outputs[1], outputs[2], outputs[3])//channels.WeakTee(a, outputs[0], outputs[1], outputs[2], outputs[3])go func() {for i := 0; i < 5; i++ {a.In() <- i}a.Close()}()for i := 0; i < 20; i++ {var v interface{}var j intselect {case v = <-outputs[0].Out():j = 0case v = <-outputs[1].Out():j = 1case v = <-outputs[2].Out():j = 2case v = <-outputs[3].Out():j = 3}fmt.Printf("channel#%d: %d\n", j, v)}
}
3/ Multiplex
func testMulti() {fmt.Println("multi:")a := channels.NewNativeChannel(channels.None)inputs := []channels.Channel{channels.NewNativeChannel(channels.None),channels.NewNativeChannel(channels.None),channels.NewNativeChannel(channels.None),channels.NewNativeChannel(channels.None),}channels.Multiplex(a, inputs[0], inputs[1], inputs[2], inputs[3])//channels.WeakMultiplex(a, inputs[0], inputs[1], inputs[2], inputs[3])go func() {for i := 0; i < 5; i++ {for j := range inputs {inputs[j].In() <- i}}for i := range inputs {inputs[i].Close()}}()for v := range a.Out() {fmt.Printf("%d ", v)}
}
4/ Pipe
func testPipe() {fmt.Println("pipe:")a := channels.NewNativeChannel(channels.None)b := channels.NewNativeChannel(channels.None)channels.Pipe(a, b)// channels.WeakPipe(a, b)go func() {for i := 0; i < 5; i++ {a.In() <- i}a.Close()}()for v := range b.Out() {fmt.Printf("%d ", v)}
}
集合操作
从channel的行为来看,它看起来很像一个数据流,所以我们可以实现一些类似Scala 集合的操作。
Scala的集合类提供了丰富的操作(方法), 当然其它的一些编程语言或者框架也提供了类似的方法, 比如Apache Spark、Java Stream、ReactiveX等。
下面列出了一些方法的实现,我相信经过一些人的挖掘,相关的方法可以变成一个很好的类库,但是目前我们先看一些例子。
1/ skip
skip函数是从一个channel中跳过开一些数据,然后才开始读取。
1.1 skipN
skipN跳过开始的N个数据。
func skipN(done <-chan struct{}, valueStream <-chan interface{}, num int) <-chan interface{} {takeStream := make(chan interface{})go func() {defer close(takeStream)for i := 0; i < num; i++ {select {case <-done:returncase takeStream <- <-valueStream:}}}()return takeStream
}
1.2 skipFn
skipFn 提供Fn函数为true的数据,比如跳过偶数。
func skipFn(done <-chan struct{}, valueStream <-chan interface{}, fn func(interface{}) bool) <-chan interface{} {takeStream := make(chan interface{})go func() {defer close(takeStream)for {select {case <-done:returncase v := <-valueStream:if !fn(v) {takeStream <- v}}}}()return takeStream
}
1.3 skipWhile
跳过开头函数fn为true的数据。
func skipWhile(done <-chan struct{}, valueStream <-chan interface{}, fn func(interface{}) bool) <-chan interface{} {takeStream := make(chan interface{})go func() {defer close(takeStream)take := falsefor {select {case <-done:returncase v := <-valueStream:if !take {take = !fn(v)if !take {continue}}takeStream <- v}}}()return takeStream
}
2/ take
skip的反向操作,读取一部分数据。
2.1 takeN
takeN 读取开头N个数据。
func takeN(done <-chan struct{}, valueStream <-chan interface{}, num int) <-chan interface{} {takeStream := make(chan interface{})go func() {defer close(takeStream)for i := 0; i < num; i++ {select {case <-done:returncase takeStream <- <-valueStream:}}}()return takeStream
}
2.2 takeFn
takeFn 只筛选满足fn的数据。
func takeFn(done <-chan struct{}, valueStream <-chan interface{}, fn func(interface{}) bool) <-chan interface{} {takeStream := make(chan interface{})go func() {defer close(takeStream)for {select {case <-done:returncase v := <-valueStream:if fn(v) {takeStream <- v}}}}()return takeStream
}
2.3 takeWhile
takeWhile只挑选开头满足fn的数据。
func takeWhile(done <-chan struct{}, valueStream <-chan interface{}, fn func(interface{}) bool) <-chan interface{} {takeStream := make(chan interface{})go func() {defer close(takeStream)for {select {case <-done:returncase v := <-valueStream:if !fn(v) {return}takeStream <- v}}}()return takeStream
}
3/ flat
平展(flat)操作是一个有趣的操作。
如果输入是一个channel,channel中的数据还是相同类型的channel, 那么flat将返回一个输出channel,输出channel中的数据是输入的各个channel中的数据。
它与扇入不同,扇入的输入channel在调用的时候就是固定的,并且以数组的方式提供,而flat的输入是一个channel,可以运行时随时的加入channel。
func orDone(done <-chan struct{}, c <-chan interface{}) <-chan interface{} {valStream := make(chan interface{})go func() {defer close(valStream)for {select {case <-done:returncase v, ok := <-c:if ok == false {return}select {case valStream <- v:case <-done:}}}}()return valStream
}
func flat(done <-chan struct{}, chanStream <-chan <-chan interface{}) <-chan interface{} {valStream := make(chan interface{})go func() {defer close(valStream)for {var stream <-chan interface{}select {case maybeStream, ok := <-chanStream:if ok == false {return}stream = maybeStreamcase <-done:return}for val := range orDone(done, stream) {select {case valStream <- val:case <-done:}}}}()return valStream
}
4/ map
map和reduce是一组常用的操作。
map将一个channel映射成另外一个channel, channel的类型可以不同。
func mapChan(in <-chan interface{}, fn func(interface{}) interface{}) <-chan interface{} {out := make(chan interface{})if in == nil {close(out)return out}go func() {defer close(out)for v := range in {out <- fn(v)}}()return out
}
比如你可以处理一个公司员工工资的channel, 输出一个扣税之后的员工工资的channel。因为map
是go的关键字,所以我们不能命名函数类型为map
,这里用mapChan
代替。
5/ reduce
func reduce(in <-chan interface{}, fn func(r, v interface{}) interface{}) interface{} {if in == nil {return nil}out := <-infor v := range in {out = fn(out, v)}return out
}
你可以用`reduce`实现`sum`、`max`、`min`等聚合操作。
本文列出了channel的一些深入应用的模式,相信通过阅读本文,你可以更加深入的了解Go的channel类型,并在开发中灵活的应用channel。也欢迎你在评论中提出更多的 channel的应用模式。
总结
所有的代码可以在github上找到: smallnest/channels。
参考资料
- https://github.com/kat-co/concurrency-in-go-src
- https://github.com/campoy/justforfunc/tree/master/27-merging-chans
- https://github.com/eapache/channels
- https://github.com/LK4D4/trylock
- https://stackoverflow.com/questions/36391421/explain-dont-communicate-by-sharing-memory-share-memory-by-communicating
- https://github.com/lrita/gosync
- https://www.ardanlabs.com/blog/2017/10/the-behavior-of-channels.html
转载于:https://my.oschina.net/u/918889/blog/1922531
Go Channel 应用模式相关推荐
- Java通道(Channel)的实现及优势
通道(Channel): 由java.nio.channels包定义的,Channel表示IO源与目标打开的连接,Channel类似于传统的"流",只不过Channel本身不能直接 ...
- RabbitMQ-Java-04-发布订阅模式
说明 RabbitMQ-Java-04-发布订阅模式 本案例是一个Maven项目 假设你已经实现了上一节工作队列 官方文档已包含绝大多数本案例内容.请移步:https://docs.spring.io ...
- kafka channle的应用案例
kafka channle的应用案例 作者:尹正杰 版权声明:原创作品,谢绝转载!否则将追究法律责任. 最近在新公司负责大数据平台的建设,平台搭建完毕后,需要将云平台(我们公司使用的Ucloud的 ...
- python总线 rabbitmq_python - 操作RabbitMQ
介绍 RabbitMQ是一个在AMQP基础上完整的,可复用的企业消息系统.他遵循Mozilla Public License开源协议. MQ全称为Message Queue, 消息队列(MQ)是一种应 ...
- Java远程通讯技术及原理分析
在分布式服务框架中,一个最基础的问题就是远程服务是怎么通讯的,在Java领域中有很多可实现远程通讯的技术,例如:RMI.MINA.ESB.Burlap.Hessian.SOAP.EJB和JMS等,这些 ...
- Redis 发布订阅,小功能大用处,真没那么废材!
假设我们有这么一个业务场景,在网站下单支付以后,需要通知库存服务进行发货处理. 上面业务实现不难,我们只要让库存服务提供给相关的给口,下单支付之后只要调用库存服务即可. 后面如果又有新的业务,比如说积 ...
- Java 远程通讯技术及原理分析
转自:https://www.cnblogs.com/Luouy/p/7399918.html 消息模式 归根结底,企业应用系统就是对数据的处理,而对于一个拥有多个子系统的企业应用系统而言,它的基础支 ...
- Redis之高级特性
一 慢查询分析 通过慢查询分析,可以扎到有问题命令,然后进行分析.一般而言都是设置一个阀值,当查询时间超过这个阀值,就会将这个语句或者命令记录下来. 而且需要注意的是,慢查询只是针对命令执行阶段,而不 ...
- Redis基础(九)——发布与订阅
文章目录 发布与订阅 发布与订阅 通过执行SUBSCRIBE命令,客户端可以订阅一个或多个频道,当其他客户端向频道发送消息时,订阅者或者匹配频道的订阅者都会收到消息 原理 订阅:当客户端执行SUBSC ...
最新文章
- 深入研究Java类加载机制
- mysql 定时同步数据_如何定时备份Mysql数据库数据?
- [转]gtest使用
- ORA-01114: 将块写入文件 35 时出现 IO 错误
- 软考高级网络规划设计师5天修炼
- C ++ 数组 | 寻找最大、最小值,数组(Array)_1
- EGit/User Guide
- java 下载 名乱码_java下载文件中文文件名乱码
- 数值计算——追赶法求解三对角方程组(附代码)
- TRNSYS 内区之间通风原理试验
- Shell 的加减乘除
- 面试题(javamysql)
- 我是一个线程(用故事讲述线程一生)
- 解析TCP连接之“三次握手”和“四次挥手”
- CIKM2020 | 最新9篇推荐系统相关论文
- TDengine集群搭建
- 使用devops的团队_跨职能DevOps团队的8个角色
- linux搭建音视频服务器,Linux平台部署音视频SDK实现即时通讯功能
- 到圣诞节了,不得不庆祝一下,用C++ Beep函数做了一个小程序
- android切换横竖,Android横竖屏切换工具