절차적 프로그래밍 및 객체 지향과 마찬가지로 좋은 프로그래밍 모델은 현실 세계의 다양한 문제를 해결할 수 있는 매우 단순한 핵심과 풍부한 확장 기능을 갖춰야 합니다. 이 글에서는 GO 언어를 예로 들어 핵심과 확장을 설명합니다.
동시 모드 커널
이 동시 모드 커널에는 코루틴과 채널만 필요합니다. 코루틴은 코드 실행을 담당하고, 채널은 코루틴 간 이벤트 전달을 담당합니다.
동시 프로그래밍은 항상 매우 어려운 작업이었습니다. 좋은 동시성 프로그램을 작성하려면 스레드, 잠금, 세마포어, 장벽은 물론 CPU가 캐시를 업데이트하는 방식까지 이해해야 하는데, 모두 이상한 성격을 갖고 있으며 함정으로 가득 차 있습니다. 저자는 꼭 필요한 경우가 아니면 이러한 기본 동시 요소를 직접 작동하지 않습니다. 간단한 동시성 패턴에는 이러한 복잡한 하위 수준 요소가 필요하지 않으며 코루틴과 채널만 있으면 충분합니다.
코루틴은 가벼운 스레드입니다. 절차적 프로그래밍에서는 프로시저를 호출할 때 반환하기 전에 실행이 끝날 때까지 기다려야 합니다. 코루틴을 호출하면 실행이 완료될 때까지 기다릴 필요가 없으며 즉시 반환됩니다.
코루틴은 매우 가볍습니다. Go 언어는 하나의 프로세스에서 수십만 개의 코루틴을 실행하면서도 여전히 높은 성능을 유지할 수 있습니다. 일반 플랫폼의 경우 프로세스에 수천 개의 스레드가 있으면 CPU가 컨텍스트 전환으로 바빠져 성능이 급격히 떨어집니다. 스레드를 무작위로 생성하는 것은 좋은 생각이 아니지만 코루틴을 많이 사용할 수 있습니다.
Channel은 코루틴 간의 데이터 전송 채널입니다. 채널은 값이나 참조가 될 수 있는 많은 코루틴 간에 데이터를 전달할 수 있습니다. 채널은 두 가지 방법으로 사용될 수 있습니다.
코루틴은 채널에 데이터를 넣으려고 시도할 수 있습니다. 채널이 꽉 차면 채널이 데이터를 넣을 수 있을 때까지 코루틴이 일시 중지됩니다.
코루틴은 채널에 데이터를 요청하려고 시도할 수 있습니다. 채널에 데이터가 없으면 채널이 데이터를 반환할 때까지 코루틴이 일시 중지됩니다.
이런 방식으로 채널은 데이터를 전송하는 동안 코루틴의 실행을 제어할 수 있습니다. 이벤트 중심적이며 차단 대기열과 비슷합니다. 이 두 가지 개념은 매우 간단하며 각 언어 플랫폼에는 해당 구현이 있습니다. Java와 C에는 두 가지를 모두 구현할 수 있는 라이브러리도 있습니다.
코루틴과 채널이 있는 한 동시성 문제는 우아하게 해결될 수 있습니다. 다른 동시성 관련 개념을 사용할 필요는 없습니다. 그렇다면 이 두 개의 날카로운 칼날을 사용하여 다양한 실제 문제를 해결하는 방법은 무엇입니까?
동시 모드의 확장
스레드에 비해 코루틴은 대량으로 생성될 수 있습니다. 이 문을 열면 새로운 용도를 확장할 수 있고, 함수가 "서비스"를 반환하도록 하고, 루프를 동시에 실행하고, 변수를 공유할 수 있습니다. 그러나 새로운 사용법의 출현은 또한 새로운 골치 아픈 문제를 가져오고, 코루틴도 누출되며 부적절한 사용은 성능에 영향을 미칩니다. 아래에서는 다양한 사용법과 문제점을 하나씩 소개하겠습니다. 데모용 코드는 간결하고 명확하며 모든 기능을 지원하기 때문에 GO 언어로 작성되었습니다.
1. Generator
때로는 지속적으로 데이터를 생성할 수 있는 기능이 필요할 때가 있습니다. 예를 들어, 이 함수는 파일을 읽고, 네트워크를 읽고, 자체 성장 시퀀스를 생성하고, 난수를 생성할 수 있습니다. 이러한 동작은 파일 경로와 같은 함수의 일부 변수를 아는 것이 특징입니다. 그런 다음 계속 호출하여 새 데이터를 반환합니다.
다음은 동시에 실행되는 난수 생성기의 예입니다.
// 函数rand_generator_1 ,返回 int funcrand_generator_1() int { return rand.Int() } // 上面是一个函数,返回一个int。假如rand.Int()这个函数调用需要很长时间等待,那该函数的调用者也会因此而挂起。所以我们可以创建一个协程,专门执行rand.Int()。 // 函数rand_generator_2,返回通道(Channel) funcrand_generator_2() chan int { // 创建通道 out := make(chan int) // 创建协程 go func() { for { //向通道内写入数据,如果无人读取会等待 out <- rand.Int() } }() return out } funcmain() { // 生成随机数作为一个服务 rand_service_handler :=rand_generator_2() // 从服务中读取随机数并打印 fmt.Printf("%d\n",<-rand_service_handler) }
위 함수는 rand.Int()를 동시에 실행할 수 있습니다. 한 가지 주목해야 할 점은 함수의 반환이 "서비스"로 이해될 수 있다는 것입니다. 하지만 무작위 데이터를 얻어야 할 경우 언제든지 이 서비스에서 액세스할 수 있습니다. 해당 데이터가 이미 준비되어 있으므로 기다릴 필요가 없으며 언제든지 사용할 수 있습니다.
이 서비스를 자주 호출하지 않는다면 코루틴 하나면 우리의 요구 사항을 충족하기에 충분합니다. 하지만 액세스 권한이 많이 필요하다면 어떻게 될까요? 아래에 소개된 다중화 기술을 사용하여 여러 생성기를 시작한 다음 이를 대규모 서비스에 통합할 수 있습니다.
생성기를 호출하면 "서비스"가 반환될 수 있습니다. 지속적으로 데이터를 얻는 상황에서 사용할 수 있습니다. 데이터 읽기, ID 생성, 타이머까지 다양한 용도로 사용됩니다. 이것은 프로그램을 동시성으로 만드는 매우 간결한 아이디어입니다.
2. 멀티플렉싱
멀티플렉싱은 여러 대기열을 동시에 처리할 수 있는 기술입니다. Apache는 각 연결을 처리하는 프로세스가 필요하므로 동시성 성능이 그다지 좋지 않습니다. Nginx는 멀티플렉싱 기술을 사용하여 하나의 프로세스가 여러 연결을 처리할 수 있도록 하므로 동시성 성능이 더 좋습니다.
마찬가지로 코루틴의 경우에도 멀티플렉싱이 필요하지만 다릅니다. 멀티플렉싱은 여러 유사한 소규모 서비스를 하나의 대규모 서비스로 통합할 수 있습니다.
那么让我们用多路复用技术做一个更高并发的随机数生成器吧。
// 函数rand_generator_3 ,返回通道(Channel) funcrand_generator_3() chan int { // 创建两个随机数生成器服务 rand_generator_1 := rand_generator_2() rand_generator_2 := rand_generator_2() //创建通道 out := make(chan int) //创建协程 go func() { for { //读取生成器1中的数据,整合 out <-<-rand_generator_1 } }() go func() { for { //读取生成器2中的数据,整合 out <-<-rand_generator_2 } }() return out }
上面是使用了多路复用技术的高并发版的随机数生成器。通过整合两个随机数生成器,这个版本的能力是刚才的两倍。虽然协程可以大量创建,但是众多协程还是会争抢输出的通道。
Go语言提供了Select关键字来解决,各家也有各家窍门。加大输出通道的缓冲大小是个通用的解决方法。
多路复用技术可以用来整合多个通道。提升性能和操作的便捷。配合其他的模式使用有很大的威力。
3、Future技术
Future是一个很有用的技术,我们常常使用Future来操作线程。我们可以在使用线程的时候,可以创建一个线程,返回Future,之后可以通过它等待结果。 但是在协程环境下的Future可以更加彻底,输入参数同样可以是Future的。
调用一个函数的时候,往往是参数已经准备好了。调用协程的时候也同样如此。但是如果我们将传入的参 数设为通道,这样我们就可以在不准备好参数的情况下调用函数。这样的设计可以提供很大的自由度和并发度。函数调用和函数参数准备这两个过程可以完全解耦。 下面举一个用该技术访问数据库的例子。
//一个查询结构体 typequery struct { //参数Channel sql chan string //结果Channel result chan string } //执行Query funcexecQuery(q query) { //启动协程 go func() { //获取输入 sql := <-q.sql //访问数据库,输出结果通道 q.result <- "get" + sql }() } funcmain() { //初始化Query q := query{make(chan string, 1),make(chan string, 1)} //执行Query,注意执行的时候无需准备参数 execQuery(q) //准备参数 q.sql <- "select * fromtable" //获取结果 fmt.Println(<-q.result) }
上面利用Future技术,不单让结果在Future获得,参数也是在Future获取。准备好参数后,自动执行。Future和生成器的区别在 于,Future返回一个结果,而生成器可以重复调用。还有一个值得注意的地方,就是将参数Channel和结果Channel定义在一个结构体里面作为 参数,而不是返回结果Channel。这样做可以增加聚合度,好处就是可以和多路复用技术结合起来使用。
Future技术可以和各个其他技术组合起来用。可以通过多路复用技术,监听多个结果Channel,当有结果后,自动返回。也可以和生成器组合使用,生 成器不断生产数据,Future技术逐个处理数据。Future技术自身还可以首尾相连,形成一个并发的pipe filter。这个pipe filter可以用于读写数据流,操作数据流。
Future是一个非常强大的技术手段。可以在调用的时候不关心数据是否准备好,返回值是否计算好的问题。让程序中的组件在准备好数据的时候自动跑起来。
4、并发循环
循环往往是性能上的热点。如果性能瓶颈出现在CPU上的话,那么九成可能性热点是在一个循环体内部。所以如果能让循环体并发执行,那么性能就会提高很多。
要并发循环很简单,只有在每个循环体内部启动协程。协程作为循环体可以并发执行。调用启动前设置一个计数器,每一个循环体执行完毕就在计数器上加一个元素,调用完成后通过监听计数器等待循环协程全部完成。
//建立计数器 sem :=make(chan int, N); //FOR循环体 for i,xi:= range data { //建立协程 go func (i int, xi float) { doSomething(i,xi); //计数 sem <- 0; } (i, xi); } // 等待循环结束 for i := 0; i < N; ++i { <-sem }
上面是一个并发循环例子。通过计数器来等待循环全部完成。如果结合上面提到的Future技术的话,则不必等待。可以等到真正需要的结果的地方,再去检查数据是否完成。
通过并发循环可以提供性能,利用多核,解决CPU热点。正因为协程可以大量创建,才能在循环体中如此使用,如果是使用线程的话,就需要引入线程池之类的东西,防止创建过多线程,而协程则简单的多。
5、ChainFilter技术
前面提到了Future技术首尾相连,可以形成一个并发的pipe filter。这种方式可以做很多事情,如果每个Filter都由同一个函数组成,还可以有一种简单的办法把他们连起来。
由于每个Filter协程都可以并发运行,这样的结构非常有利于多核环境。下面是一个例子,用这种模式来产生素数。
// Aconcurrent prime sieve packagemain // Sendthe sequence 2, 3, 4, ... to channel 'ch'. funcGenerate(ch chan<- int) { for i := 2; ; i++ { ch<- i // Send 'i' to channel 'ch'. } } // Copythe values from channel 'in' to channel 'out', //removing those divisible by 'prime'. funcFilter(in <-chan int, out chan<- int, prime int) { for { i := <-in // Receive valuefrom 'in'. if i%prime != 0 { out <- i // Send'i' to 'out'. } } } // Theprime sieve: Daisy-chain Filter processes. funcmain() { ch := make(chan int) // Create a newchannel. go Generate(ch) // Launch Generate goroutine. for i := 0; i < 10; i++ { prime := <-ch print(prime, "\n") ch1 := make(chan int) go Filter(ch, ch1, prime) ch = ch1 } }
上面的程序创建了10个Filter,每个分别过滤一个素数,所以可以输出前10个素数。
Chain-Filter通过简单的代码创建并发的过滤器链。这种办法还有一个好处,就是每个通道只有两个协程会访问,就不会有激烈的竞争,性能会比较好
6、共享变量
协程之间的通信只能够通过通道。但是我们习惯于共享变量,而且很多时候使用共享变量能让代码更简洁。比如一个Server有两个状态开和关。其他仅仅希望获取或改变其状态,那又该如何做呢。可以将这个变量至于0通道中,并使用一个协程来维护。
下面的例子描述如何用这个方式,实现一个共享变量。
//共享变量有一个读通道和一个写通道组成 typesharded_var struct { reader chan int writer chan int } //共享变量维护协程 funcsharded_var_whachdog(v sharded_var) { go func() { //初始值 var value int = 0 for { //监听读写通道,完成服务 select { case value =<-v.writer: case v.reader <-value: } } }() } funcmain() { //初始化,并开始维护协程 v := sharded_var{make(chan int),make(chan int)} sharded_var_whachdog(v) //读取初始值 fmt.Println(<-v.reader) //写入一个值 v.writer <- 1 //读取新写入的值 fmt.Println(<-v.reader) }
这样,就可以在协程和通道的基础上实现一个协程安全的共享变量了。定义一个写通道,需要更新变量的时候,往里写新的值。再定义一个读通道,需要读的时候,从里面读。通过一个单独的协程来维护这两个通道。保证数据的一致性。
一般来说,协程之间不推荐使用共享变量来交互,但是按照这个办法,在一些场合,使用共享变量也是可取的。很多平台上有较为原生的共享变量支持,到底用那种 实现比较好,就见仁见智了。另外利用协程和通道,可以还实现各种常见的并发数据结构,如锁等等,就不一一赘述。
7、协程泄漏
协程和内存一样,是系统的资源。对于内存,有自动垃圾回收。但是对于协程,没有相应的回收机制。会不会若干年后,协程普及了,协程泄漏和内存泄漏一样成为 程序员永远的痛呢?
一般而言,协程执行结束后就会销毁。协程也会占用内存,如果发生协程泄漏,影响和内存泄漏一样严重。轻则拖慢程序,重则压垮机器。
C和C++都是没有自动内存回收的程序设计语言,但只要有良好的编程习惯,就能解决规避问题。对于协程是一样的,只要有好习惯就可以了。
只有两种情况会导致协程无法结束。一种情况是协程想从一个通道读数据,但无人往这个通道写入数据,或许这个通道已经被遗忘了。还有一种情况是程想往一个通道写数据,可是由于无人监听这个通道,该协程将永远无法向下执行。下面分别讨论如何避免这两种情况。
对于协程想从一个通道读数据,但无人往这个通道写入数据这种情况。解决的办法很简单,加入超时机制。对于有不确定会不会返回的情况,必须加入超时,避免出 现永久等待。
另外不一定要使用定时器才能终止协程。也可以对外暴露一个退出提醒通道。任何其他协程都可以通过该通道来提醒这个协程终止。
对于协程想往一个通道写数据,但通道阻塞无法写入这种情况。解决的办法也很简单,就是给通道加缓冲。但前提是这个通道只会接收到固定数目的写入。
比方说, 已知一个通道最多只会接收N次数据,那么就将这个通道的缓冲设置为N。那么该通道将永远不会堵塞,协程自然也不会泄漏。也可以将其缓冲设置为无限,不过这 样就要承担内存泄漏的风险了。等协程执行完毕后,这部分通道内存将会失去引用,会被自动垃圾回收掉。
funcnever_leak(ch chan int) { //初始化timeout,缓冲为1 timeout := make(chan bool, 1) //启动timeout协程,由于缓存为1,不可能泄露 go func() { time.Sleep(1 * time.Second) timeout <- true }() //监听通道,由于设有超时,不可能泄露 select { case <-ch: // a read from ch hasoccurred case <-timeout: // the read from ch has timedout } }
上面是个避免泄漏例子。使用超时避免读堵塞,使用缓冲避免写堵塞。
和内存里面的对象一样,对于长期存在的协程,我们不用担心泄漏问题。一是长期存在,二是数量较少。要警惕的只有那些被临时创建的协程,这些协程数量大且生 命周期短,往往是在循环中创建的,要应用前面提到的办法,避免泄漏发生。协程也是把双刃剑,如果出问题,不但没能提高程序性能,反而会让程序崩溃。但就像 内存一样,同样有泄漏的风险,但越用越溜了。
并发模式之实现
在并发编程大行其道的今天,对协程和通道的支持成为各个平台比不可少的一部分。虽然各家有各家的叫法,但都能满足协程的基本要求—并发执行和可大量创建。笔者对他们的实现方式总结了一下。
下面列举一些已经支持协程的常见的语言和平台。
GoLang 和Scala作为最新的语言,一出生就有完善的基于协程并发功能。Erlang最为老资格的并发编程语言,返老还童。其他二线语言则几乎全部在新的版本中加入了协程。
세계 3대 주류 플랫폼인 C/C++와 Java가 코루틴에 대한 언어 수준의 기본 지원을 제공하지 않는다는 것은 놀라운 일입니다. 그들은 모두 바꿀 수도 없고, 바꿀 필요도 없는 무거운 역사를 짊어지고 있습니다. 그러나 코루틴을 사용하는 다른 방법이 있습니다.
Java 플랫폼에서 코루틴을 구현하는 방법에는 여러 가지가 있습니다.
· 가상 머신 수정: JVM을 패치하여 코루틴을 구현합니다. 이 구현은 효과적이지만 크로스 플랫폼의 이점을 잃습니다.
· 바이트코드 수정: compile 완료되면 바이트코드를 강화하거나 새로운 JVM 언어를 사용하십시오. 컴파일 난이도가 약간 증가합니다.
· JNI 사용: 사용하기 쉽지만 크로스 플랫폼이 불가능한 Jar 패키지에서 JNI를 사용합니다.
· 스레드를 사용하여 코루틴 시뮬레이션: 코루틴을 무겁게 만들고 JVM 스레드 구현에 전적으로 의존합니다.
그중에서도 바이트코드를 수정하는 방법이 비교적 일반적입니다. 이 구현 방법은 성능과 이식성의 균형을 맞출 수 있기 때문입니다. 가장 대표적인 JVM 언어인 Scale은 코루틴 동시성을 잘 지원할 수 있습니다. 인기 있는 Java Actor 모델 클래스 라이브러리인 akka도 바이트코드를 수정하여 구현된 코루틴입니다.
C 언어의 경우 코루틴은 스레드와 동일합니다. 이는 다양한 시스템 호출을 사용하여 수행할 수 있습니다. 상대적으로 발전된 개념인 코루틴에는 구현 방법이 너무 많기 때문에 여기서는 이에 대해 논의하지 않습니다. 보다 주류 구현에는 libpcl, coro, lthread 등이 포함됩니다.
C++의 경우 Boost 구현과 기타 오픈 소스 라이브러리가 있습니다. C++ 기반 동시성 확장을 제공하는 μC++라는 언어도 있습니다.
이 프로그래밍 모델은 많은 언어 플랫폼에서 널리 지원되었으며 더 이상 틈새 시장이 아니라는 것을 알 수 있습니다. 사용하고 싶다면 언제든지 도구 상자에 추가할 수 있습니다.
Go 언어와 관련된 더 많은 기사를 보려면 go 언어 튜토리얼 칼럼을 주목해 주세요.
위 내용은 Go 언어의 동시성 그래픽 튜토리얼의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!