当前位置: 首页 > 科技观察

Go并行性和并发性:有什么区别?

时间:2023-03-12 10:04:56 科技观察

大家好,我是程序员幽灵。并发和并行,在Go刚发布的时候,官方一直在强调这两点的区别。可能新手还是一头雾水。这次我会做一个系列,详细讲解并发和并行。软件中的并行性是指令的同时执行。每种编程语言要么实现自己的库,要么提供语言级别的支持,例如Go。并行性允许软件工程师通过在多个处理器上并行执行任务来避开硬件的物理限制。由于正确利用并行构建的复杂性,应用程序的并行性取决于构建软件的工程师的技能。并行任务示例:多人在餐厅点餐杂货店多名收银员多核CPU实际上,任何应用程序都具有多层并行性。有应用程序本身的并行度,由应用程序开发人员定义,以及CPU在操作系统协调的物理硬件上执行的指令的并行度(或多路复用)。1.并行度的构建应用程序开发人员使用抽象来描述应用程序的并行度。这些抽象在实现并行性的每种语言中通常不同,但概念是相同的。例如,在C中,并行性是通过使用pthreads定义的,而在Go中,并行性是通过使用goroutines定义的。进程进程是一个执行单元,包括它自己的“程序计数器、寄存器和变量。从概念上讲,每个进程都有自己的虚拟CPU”。理解这一点很重要,因为创建和管理流程会产生开销。除了创建进程的开销外,每个进程只能访问自己的内存。这意味着该进程无法访问其他进程的内存。如果有多个执行线程(并行任务)需要访问某些共享资源,这将是一个问题。线程引入线程是为了在同一进程内但在不同的并行执行单元上授予对共享内存的访问权限。线程几乎是它们自己的进程,但可以访问父进程的共享地址空间。线程的开销比进程低得多,因为它们不必为每个线程创建一个新进程,而且资源可以共享或重用。这是Ubuntu18.04的一个示例,比较了分叉进程与创建线程的开销:#Borrowedfromhttps://stackoverflow.com/a/52231151/834319#Ubuntu18.04start_method:fork#===============================过程的结果:count1000.000000mean0.002081std0.000288min0.00146625%0.00186650%0.00197375%0.002268max0.003365Minimumwith1.47ms--------------------------------------------------------resultsforThread:count1000.000000mean0.000054std0.000013min0.00004425%0.00004750%0.00005175%0.000058max0.000319Minimumwith43.89μs--------------------------------------------------------进程任务的最小启动时间比线程长33.41倍。临界区临界区是进程中各种并行进程任务所需的共享内存区域。这些部分可能是共享数据、类型或其他资源。并行的复杂性由于进程的线程在同一内存空间中执行,因此存在多个线程同时访问临界区的风险。这可能会导致应用程序中的数据损坏或其他意外行为。当多个线程同时访问共享内存时会出现两个主要问题。竞争条件竞争条件是指多个并行执行线程在没有任何保护的情况下直接读取或写入共享资源。这可能会导致资源中存储的数据损坏或导致其他意外行为。例如,想象一个进程,其中一个线程正在从共享内存位置读取一个值,而另一个线程正在将一个新值写入同一位置。如果第一个线程在第二个线程写入值之前读取该值,则第一个线程将读取旧值。这可能会导致应用程序无法按预期运行的情况。死锁当两个或多个线程正在等待对方做某事时,就会发生死锁。这会导致应用程序挂起或崩溃。例如,一个线程针对临界区执行等待满足条件,而另一线程针对同一临界区执行并等待来自另一线程的条件得到满足。如果第一个线程正在等待满足条件,而第二个线程正在等待第一个线程,则两个线程将永远等待。第二种形式的死锁可能发生在试图通过使用互斥锁来防止竞争条件时。屏障是同步点,用于管理进程内多个线程对共享资源或临界区的访问。这些屏障允许应用程序开发人员控制并发访问,以确保不会以不安全的方式访问资源。互斥量互斥量是一种屏障,一次只允许一个线程访问共享资源。这有助于在读取或写入共享资源时通过锁定和解锁来防止竞争条件。//ExampleofamutexbarrierinGoimport("sync""fmt")varsharedstringvarsharedMusync.Mutexfuncmain(){//Startagoroutinetowritetothesharedvariablegofunc(){fori:=0;i<10;i++{write(fmt.Sprintf("%d",i))}}()//readfromthesharedvariablefori:=0;i<10;i++{read(fmt.Sprintf("%d",i))}}funcwrite(valuestring){sharedMu.Lock()deferssharedMu.Unlock()//setanewvalueforthe`shared`variableshared=value}funcread(){sharedMu.Lock()deferssharedMu.Unlock()//printthecriticalsection`shared`tostdoutfmt.Println(shared)}如果我们看上面的例子,我们可以看到共享变量是主题到互斥保护。这意味着一次只有一个线程可以访问共享变量。这确保共享变量不会被破坏并且行为可预测。注意:使用互斥锁时,确保在函数返回时释放互斥锁至关重要。例如,在Go中,这可以通过使用defer关键字来完成。这确保其他线程(goroutines)可以访问共享资源。信号量信号量是一种屏障,一次只允许一定数量的线程访问共享资源。这与互斥量不同,因为可以访问资源的线程数不限于一个。Go标准库中没有信号量实现。但是可以使用通道来实现。忙等待(busywaiting)忙等待是一种线程等待条件被满足的技术。通常用于等待计数器达到特定值。//Govarxintfuncmain(){gofunc(){fori:=0;i<10;i++{x=i}}()forx!=1{//Loopuntilxissetto1fmt.Println("Waiting...")time.Sleep(time.Millisecond*100)}}因此,忙等待需要一个循环来等待满足读取或写入共享资源的条件,并且必须由互斥锁保护以确保正确的行为。上述示例的问题在于遍历不受互斥锁保护的临界区。这可能会导致竞争条件,其中值被迭代但它可能已被进程的另一个线程更改。事实上,上面的例子也是竞争条件的一个很好的例子。应用程序可能永远不会退出,因为不能保证循环足够快以读取x=1处的值,这意味着循环永远不会退出。如果我们用互斥锁保护变量x,循环将受到保护,应用程序将退出,但这仍然不完美,循环设置x仍然足够快,可以在读取值的循环执行之前命中两次互斥锁(虽然不太可能)。import"sync"varxintvarxMusync.Mutexfuncmain(){gofunc(){fori:=0;i<10;i++{xMu.Lock()x=ixMu.Unlock()}}()varvalueintforvalue!=1{//Loopuntilxissetto1xMu。Lock()value=x//Setvalue==xxMu.Unlock()}}一般来说,忙等待不是一个好办法。最好使用信号量或互斥量来确保关键部分受到保护。我们将介绍在Go中处理此问题的更好方法,但它说明了编写“正确”的可并行代码的复杂性。WaitGroupWaitGroup是确保所有并行代码路径在继续之前已完成处理的方法。在Go中,这是使用标准库中的sync.WaitGroup完成的。//例子`sync.WaitGroup`inGoimport("sync")funcmain(){varwgsync.WaitGroupvarNint=10wg.Add(N)fori:=0;i