并发控制

并发控制

所有的线程安全性问题,都可以归结于同一个原因: 共享的可变状态。首先来看状态的共享,在 Java 中,如果类的某个域被声明为 public,或者通过 public 方法返回了某个 private 域的引用,那么这个域就可以被其它对象访问到,可以认为基于该类创建的对象,共享了其状态。一旦状态被共享,宿主对象就失去了状态的完全控制权,你无法预知其它对象会对共享状态做怎样的误操作。

状态的可变性问题则来源于我们将在内存模型章节讨论的可见性、原子性与有序性问题。遇到活跃性问题的并发程序,被称为(Poor Concurrency)应用程序,活跃性问题可能有很多原因引起:

  • 线程开销:线程虽然比进程轻量,但是线程的管理仍然需要消耗一定的系统资源。比如线程上下文切换,需要 5000~10000 个时钟周期,大约是几微秒,如果线程上下文切换过于频繁,就会对活跃性造成影响。

  • 阻塞:当线程被不恰当地置为阻塞状态时,后续的指令得不到执行,于是就会出现活跃性问题。

  • 死锁:死锁是最常见的活跃性风险。当两个线程互相等待对方持有的资源时,就会发生死锁。不恰当的加锁解锁顺序,以及错误的资源管理策略,都有可能导致死锁。死锁往往出现在最糟糕的时候 —— 高负载的情形。

  • 活锁:当线程不断地重试某个失败的操作时,就会发生活锁。此时线程虽然不会被阻塞,但也不能继续执行。

要避免线程活跃性问题,需要我们对并发机制有深刻了解,并养成良好的并发编程习惯。常见的解决并发活跃性问题的手段有:

  • 避免使用锁。这是釜底抽薪、从源头解决的问题的办法。没有买卖就没有伤害,没有锁就不会陷入单线程执行模式,就不会有线程活跃性问题。可以使用上文提到的避免可变状态、避免共享状态等手段,来规避对锁的使用。

  • 降低锁的粒度。如果加锁不可避免,那么可以尝试降低锁的粒度,只在确实需要使用锁的地方才使用它。比如可以在一个方法内部,只对其中的某几行代码,引入 synchornized 对代码块进行同步。

  • 加上超时限制。并发程序可能会以出乎意料的方式,陷入长时间的锁等待,甚至是死锁。作为止血方案,可以使用显示锁(Lock 类),并指定超时时限(Timeout), 在超过该时间之后就返回一个失败信息,避免永久等待。

总结而言,在并发控制中我们会考虑线程协作、互斥与锁、并发容器等方面。

进程通信

并发控制中主要考虑线程之间的通信(线程之间以何种机制来交换信息)与同步(读写等待,竞态条件等)模型,在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。Java 就是典型的共享内存模式的通信机制;而 Go 则是提倡以消息传递方式实现内存共享,而非通过共享来实现通信。

在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。同步是指程序用于控制不同线程之间操作发生相对顺序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

常见的进程通信方式有以下几种:

  • 管道(Pipe):管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用,其中进程的亲缘关系通常是指父子进程关系。

  • 消息队列(Message Queue):消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。

  • 信号量(Semophore):信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。

  • 共享内存(Shared Memory):共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量配合使用,来实现进程间的同步和通信。

  • 套接字(Socket):套接字也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同主机间的进程通信。