资讯专栏INFORMATION COLUMN

第10章:并发和分布式编程 10.1并发性和线程安全性

instein / 3141人阅读

摘要:并发模块本身有两种不同的类型进程和线程,两个基本的执行单元。调用以启动新线程。在大多数系统中,时间片发生不可预知的和非确定性的,这意味着线程可能随时暂停或恢复。

大纲

什么是并发编程?
进程,线程和时间片
交织和竞争条件
线程安全

策略1:监禁

策略2:不可变性

策略3:使用线程安全数据类型

策略4:锁定和同步

如何做安全论证
总结

什么是并发编程?

并发
并发性:多个计算同时发生。

在现代编程中无处不在:

网络上的多台计算机中的多台计算机

在一台计算机上运行的多个应用程序一台计算机上的多个应用程序

计算机中的多个处理器(今天,通常是单个芯片上的多个处理器内核)一个CPU上的多核处理器

并发在现代编程中至关重要:

网站必须处理多个同时使用的用户。多用户并发请求服务器的计算资源

移动应用程序需要在云中执行一些处理。 App在手机端和在云端都有计算

图形用户界面几乎总是需要不中断用户的后台工作。例如,Eclipse在编辑它时编译你的Java代码。 GUI的前端用户操作和后台的计算

为什么要“并发”?

处理器时钟速度不再增加。摩尔定律失效了
相反,我们每个新一代芯片都会获得更多内核。 “核”变得越来越多
为了让计算更快运行,我们必须将计算分解为并发块。为了充分利用多核和多处理器,需要将程序转化为并行执行

并行编程的两种模型

共享内存:并发模块通过在内存中读写共享对象进行交互。
共享内存:在内存中读写共享数据
消息传递:并发模块通过通信通道相互发送消息进行交互。模块发送消息,并将传入的消息发送到每个模块以便处理。
消息传递:通过信道(channel)交换消息

共享内存

共享内存模型的示例:

A和B可能是同一台计算机中的两个处理器(或处理器核),共享相同的物理内存。

两个处理器,共享内存

A和B可能是在同一台计算机上运行的两个程序,它们共享一个通用文件系统及其可读取和写入的文件。

同一台机器上的两个程序,共享文件系统

A和B可能是同一个Java程序中的两个线程,共享相同的Java对象。

同一个Java的程序内的两个线程,共享的Java对象

消息传递

消息传递的例子:

A和B可能是网络中的两台计算机,通过网络连接进行通信。

网络上的两台计算机,通过网络连接通讯

A和B可能是一个Web浏览器和一个Web服务器

A打开与B的连接并请求网页,B将网页数据发送回A.

浏览器和Web服务器,A请求页面,B发送页面数据给A

A和B可能是即时消息客户端和服务器。

即时通讯软件的客户端和服务器

A和B可能是在同一台计算机上运行的两个程序,其输入和输出已通过管道连接,如键入命令提示符中的ls | grep。

同一台计算机上的两个程序,通过管道连接进行通讯

进程,线程,时间片

进程和线程

消息传递和共享内存模型是关于并发模块如何通信的。
并发模块本身有两种不同的类型:进程和线程,两个基本的执行单元。
并发模块的类型:进程和线程

进程是正在运行的程序的一个实例,与同一台计算机上的其他进程隔离。 特别是,它有自己的机器内存专用部分。进程:私有空间,彼此隔离

线程是正在运行的程序中的一个控制轨迹。 把它看作是正在运行的程序中的一个地方,再加上导致那个地方的方法调用堆栈(所以当线程到达返回语句时可以返回堆栈)。线程:程序内部的控制机制

(1)进程

进程抽象是一个虚拟计算机(一个独立的执行环境,具有一套完整的私有基本运行时资源,尤其是内存)。进程:拥有整台计算机的资源

它使程序感觉自己拥有整台机器

就像一台全新的计算机一样,创建了新的内存,只是为了运行该程序。

就像连接网络的计算机一样,进程通常在它们之间不共享内存。多进程之间不共享内存

进程无法访问另一个进程的内存或对象。

相比之下,新的流程自动准备好传递消息,因为它是使用标准输入输出流创建的,这些流是您在Java中使用的System.out和System.in流。进程之间通过消息传递进行协作

进程通常被视为与程序或应用程序的同义词。一般来说,进程==程序==应用

但是,用户将其视为单一应用程序实际上可能是一组协作过程。但一个应用中可能包含多个进程

为了促进进程之间的通信,大多数操作系统都支持进程间通信(IPC)资源,例如管道和套接字。 OS支持的IPC机制(pipe / socket)支持进程间通信

IPC不仅用于同一系统上的进程之间的通信,还用于不同系统上的进程。不仅是本机的多个进程之间,也可以是不同机器的多个进程之间。

Java虚拟机的大多数实现都是作为单个进程运行的。但是Java应用程序可以使用ProcessBuilder对象创建其他进程。 JVM通常运行单一进程,但也可以创建新的进程。

(2)线程

线程和多线程编程
就像一个进程代表一个虚拟计算机一样,线程抽象代表一个虚拟处理器,线程有时称为轻量级进程 进程=虚拟机;线程=虚拟CPU

制作一个新的线程模拟在由该进程表示的虚拟计算机内部制造新的处理器。

这个新的虚拟处理器运行相同的程序,并与进程中的其他线程共享相同的资源(内存,打开的文件等),即“线程存在于进程中”。程序共享,资源共享,都隶属于进程

线程自动准备好共享内存,因为线程共享进程中的所有内存。共享内存

需要特殊的努力才能获得专用于单个线程的“线程本地”内存。很难获得线程私有的内存空间(线程堆栈怎么样?)

通过创建和使用队列数据结构,还需要显式设置消息传递。通过创建消息队列在线程之间进行消息传递

线程与进程

线程是轻量级的 进程是重量级的
线程共享内存空间 进程有自己的
线程需要同步(当调用可变对象时线程保有锁) 进程不需要
杀死线程是不安全的 杀死进程是安全的

多线程执行是Java平台的基本功能。

每个应用程序至少有一个线程。每个应用至少有一个线程

从应用程序员的角度来看,你只从一个叫做主线程的线程开始。这个线程有能力创建额外的线程。主线程,可以创建其他的线程

两种创建线程的方法:

(很少使用)子类化线程。从Thread类派生子类

(更常用)实现Runnable接口并使用new Thread(..)构造函数。从Runnable接口构造线程对象

如何创建一个线程:子类Thread

子类Thread

Thread类本身实现了Runnable,尽管它的run方法什么都不做。应用程序可以继承Thread,提供自己的run()实现。

调用Thread.start()以启动新线程。

创建线程的方法:提供一个Runnable对象

提供一个Runnable对象

Runnable接口定义了一个方法run(),意在包含在线程中执行的代码。

Runnable对象被传递给Thread构造函数。

如何创建线程

一个非常常见的习惯用法是用一个匿名的Runnable启动一个线程,它消除了命名的类:
Runnable接口表示要由线程完成的工作。

为什么使用线程?

面对阻塞活动的表现

考虑一个Web服务器

在多处理器上的性能
干净地处理自然并发

在Java中,线程是生活中的事实

示例:垃圾收集器在其自己的线程中运行(回忆:第8-1节)

我们都是并发程序员......

为了利用我们的多核处理器,我们必须编写多线程代码
好消息:它很多都是为你写的

存在优秀的库(java.util.concurrent)

坏消息:你仍然必须了解基本面

有效地使用库

调试使用它们的程序

Interleaving and Race Condition

交错和竞争

(1) 时间分片(Time slicing)

在具有单个执行核心的计算机系统中,在任何给定时刻只有一个线程正在执行。虽然有多线程,但只有一个核,每个时刻只能执行一个线程

单个内核的处理时间通过称为时间分片的操作系统功能在进程和线程间共享。通过时间分片,在多个进程/线程之间共享处理器

今天的计算机系统具有多个处理器或具有多个执行核心的处理器。那么,我的计算机中只有一个或两个处理器的多个并发线程如何处理?即使是多核CPU,进程/线程的数目也往往大于核的数目

当线程数多于处理器时,并发性通过时间分片模拟,这意味着处理器在线程之间切换。时间分片

时间分片的一个例子

三个线程T1,T2和T3可能在具有两个实际处理器的机器上进行时间分割。

首先一个处理器运行线程T1,另一个运行线程T2,然后第二个处理器切换到运行线程T3。

线程T2只是暂停,直到下一个时间片在同一个处理器或另一个处理器上。

在大多数系统中,时间片发生不可预知的和非确定性的,这意味着线程可能随时暂停或恢复。时间分片是由操作系统自动调度的

(2) 线程间的共享内存

共享内存示例

线程之间的共享内存可能会导致微妙的错误!
例如:一家银行拥有使用共享内存模式的取款机,因此所有取款机都可以在内存中读取和写入相同的账户对象。
将银行简化为一个账户,在余额变量中存储美元余额,以及两个操作存款和取款,只需添加或删除美元即可:
客户使用现金机器进行如下交易:
每笔交易只是一美元存款,然后是基础提款,所以它应该保持账户余额不变。

在整个一天中,我们网络中的每台自动提款机正在处理一系列存款/提款交易。

在这一天结束时,无论有多少现钞机在运行,或者我们处理了多少交易,我们都应该预期帐户余额仍然为0.按理说,余额应该始终为0

但是如果我们运行这个代码,我们经常发现在一天结束时的余额不是0.如果多个cashMachine()调用同时运行

例如,在同一台计算机上的不同处理器上

那么在一天结束时余额可能不会为零。为什么不?

交错

假设两台取款机A和B同时在存款上工作。
以下是deposit()步骤通常如何分解为低级处理器指令的方法:
当A和B同时运行时,这些低级指令彼此交错...

(3) 竞争条件(Race Condition)

余额现在是1

A的美元丢失了!

A和B同时读取余额,计算多带带的最终余额,然后进行存储以返回新的余额

没有考虑到对方的存款。

竞争条件:程序的正确性(后置条件和不变量的满足)取决于并发计算A和B中事件的相对时间。发生这种情况时,我们说“A与B竞争”。
事件的一些交织可能是可以的,因为它们与单个非并发进程会产生什么一致,但是其他交织会产生错误的答案 - 违反后置条件或不变量。

调整代码将无济于事

所有这些版本的银行账户代码都具有相同的竞争条件!
你不能仅仅从Java代码中看出处理器将如何执行它。
你不能说出原子操作是什么。

它不是原子,因为它只是一行Java。

仅仅因为平衡标识符只在一行中出现一次才平衡一次。单行,单条语句都未必是原子的

Java编译器不会对您的代码生成的低级操作做出任何承诺。是否原子,由JVM确定

一个典型的现代Java编译器为这三个版本生成完全相同的代码!

竞争条件
关键的教训是,你无法通过观察一个表达来判断它是否会在竞争条件下安全。
竞争条件也被称为“线程干扰”

(4) 消息传递示例

现在不仅是自动取款机模块,而且账户也是模块。
模块通过相互发送消息进行交互。

传入的请求被放入一个队列中,一次处理一个请求。

发件人在等待对其请求的回答时不停止工作。它处理来自其自己队列的更多请求。对其请求的回复最终会作为另一条消息返回。

消息传递能否解决竞争条件?

不幸的是,消息传递并不能消除竞争条件的可能性。消息传递机制也无法解决竞争条件问题

假设每个账户都支持收支平衡和撤销操作,并带有相应的消息。

两台A和B取款机的用户都试图从同一账户中提取一美元。

他们首先检查余额,以确保他们永远不会超过账户余额,因为透支会触发大银行的处罚。

问题是再次交错,但是这次将消息交给银行账户,而不是A和B所执行的指令。仍然存在消息传递时间上的交错
如果账户以一美元开始,那么什么交错的信息会欺骗A和B,使他们认为他们既可以提取一美元,从而透支账户?

(5) 并发性很难测试和调试

使用测试发现竞争条件非常困难。很难测试和调试因为竞争条件导致的错误

即使一次测试发现了一个错误,也可能很难将其本地化到引发该错误的程序部分。 - - 为什么?

并发性错误表现出很差的重现性。因为交错的存在,导致很难复现错误

很难让它们以同样的方式发生两次。

指令或消息的交织取决于受环境强烈影响的事件的相对时间。

延迟是由其他正在运行的程序,其他网络流量,操作系统调度决策,处理器时钟速度的变化等引起的。

每次运行包含竞争条件的程序时,您都可能得到不同的行为。

Heisenbugs和Bohrbugs

一个heisenbug是一个软件错误,当一个人试图研究它时,它似乎消失或改变了它的行为。
顺序编程中几乎所有的错误都是bohrbugs。

并发性很难测试和调试!

当您尝试用println或调试器查看heisenbug时,甚至可能会消失!增加打印语句甚至导致这种错误消失!〜

原因是打印和调试比其他操作慢得多,通常慢100-1000倍,所以它们会显着改变操作的时间和交错。神奇的原因

因此,将一个简单的打印语句插入到cashMachine()中:
...突然间,平衡总是0,并且错误似乎消失了。但它只是被掩盖了,并没有真正固定。

3.5干扰线程自动交错的一些操作

Thread.sleep()方法
使用Thread.sleep(time)暂停执行:导致当前线程暂停指定时间段的执行。线程的休眠

这是使处理器时间可用于其他线程或可能在同一台计算机上运行的其他应用程序的有效方法。将某个线程休眠,意味着其他线程得到更多的执行机会

线程睡眠不会丢失当前线程获取的任何监视器或锁。进入休眠的线程不会失去对现有监视器或锁的所有权

Thread.interrupt()方法
一个线程通过调用Thread对象上的中断来发送一个中断,以便使用interrupt()方法中断的线程 向线程发出中断信号

t.interrupt()在其他线程里向t发出中断信号

要检查线程是否中断,请使用isInterrupted()方法。检查线程是否被中断

t.isInterrupted()检查t是否已在中断状态中

中断表示线程应该停止正在执行的操作并执行其他操作。 当某个线程被中断后,一般来说应停止其run()中的执行,取决于程序员在run()中处理

由程序员决定线程是如何响应中断的,但线程终止是非常常见的。 一般来说,线程在收到中断信号时应该中断,直接终止

但是,线程收到其他线程发来的中断信号,并不意味着一定要“停止”...

Thread.yield()方法
这种静态方法主要用于通知系统当前线程愿意“放弃CPU”一段时间。使用该方法,线程告知调度器:我可以放弃CPU的占用权,从而可能引起调度器唤醒其他线程。

总体思路是:线程调度器将选择一个不同的线程来运行而不是当前的线程。

这是线程编程中很少使用的方法,因为调度应该由调度程序负责。尽量避免在代码中使用

Thread.join()方法
join()方法用于保存当前正在运行的线程的执行,直到指定的线程死亡(执行完毕)。让当前线程保持执行,直到其执行结束

在正常情况下,我们通常拥有多个线程,线程调度程序调度线程,但不保证线程执行的顺序。一般不需要这种显式指定线程执行次序

通过使用join()方法,我们可以让一个线程等待另一个线程。

(6) 总结

并发性:同时运行多个计算
共享内存和消息传递参数

进程和线程

进程就像一台虚拟计算机;线程就像一个虚拟处理器

竞争条件

结果的正确性(后置条件和不变量)取决于事件的相对时间

多个线程共享相同的可变变量,但不协调他们正在做的事情。

这是不安全的,因为程序的正确性可能取决于其低级操作的时间安排事故。

这些想法主要以糟糕的方式与我们的优秀软件的关键属性相关联。

并发是必要的,但它会导致严重的正确性问题:

从错误安全。并发性错误是找到并修复最难的错误之一,需要仔细设计才能避免。

容易明白。预测并发代码如何与其他并发代码交错对于程序员来说非常困难。最好以这样的方式设计代码,程序员根本不必考虑交错。

线程安全

竞争条件:多个线程共享相同的可变变量,但不协调他们正在做的事情。

这是不安全的,因为程序的正确性可能取决于其低级操作时间的事故。
线程之间的“竞争条件”:作用于同一个可变数据上的多线程,彼此之间存在对该数据的访问竞争并导致交错,导致postcondition可能被违反,这是不安全的。

线程安全的意思

如果数据类型或静态方法在从多个线程使用时行为正确,则无论这些线程如何执行,都无需线程安全,也不需要调用代码进行额外协调。线程安全:ADT或方法在多线程中要执行正确

如何捕捉这个想法?

“正确行为”是指满足其规范并保留其不变性;不违反规范,保持RI

“不管线程如何执行”意味着线程可能在多个处理器上或在同一个处理器上进行时间片化;与多少处理器,如何调度线程,均无关

“没有额外的协调”意味着数据类型不能在与定时有关的调用方上设置先决条件,如“在set()进行时不能调用get()”。不需要在spec中强制要求客户端满足某种“线程安全”的义务

还记得迭代器吗?这不是线程安全的。

迭代器的规范说,你不能在迭代它的同时修改一个集合。
这是一个与调用程序相关的与时间有关的前提条件,如果违反它,Iterator不保证行为正确。

线程安全意味着什么:remove()的规范

作为这种非本地契约现象的一个症状,考虑Java集合类,这些类通常记录在客户端和实现者之间的非常明确的契约中。

尝试找到它在客户端记录关键要求的位置,以便在迭代时无法修改集合。

线程安全的四种方法

监禁数据共享。不要在线程之间共享变量。

共享不可变数据。使共享数据不可变。

线程安全数据类型共享线程安全的可变数据。将共享数据封装在为您协调的现有线程安全数据类型中。

同步 同步机制共享共享线程不安全的可变数据,对外即为线程安全的ADT。使用同步来防止线程同时访问变量。同步是您构建自己的线程安全数据类型所需的。

不要共享:在多带带的线程中隔离可变状态
不要改变:只共享不可变的状态
如果必须共享可变状态,请使用线程安全数据类型或同步

策略1:监禁

线程监禁是一个简单的想法:

通过将数据监禁在单个线程中,避免在可变数据上进行竞争。将可变数据监禁在单一线程内部,避免竞争

不要让任何其他线程直接读取或写入数据。不允许任何线程直接读写该数据

由于共享可变数据是竞争条件的根本原因,监禁通过不共享可变数据来解决。核心思想:线程之间不共享可变数据类型

局部变量总是受到线程监禁。局部变量存储在堆栈中,每个线程都有自己的堆栈。一次运行的方法可能会有多个调用,但每个调用都有自己的变量专用副本,因此变量本身受到监禁。

如果局部变量是对象引用,则需要检查它指向的对象。 如果对象是可变的,那么我们要检查对象是否被监禁 - 不能引用它,它可以从任何其他线程访问(而不是别名)。

避免全局变量

这个类在getInstance()方法中有一个竞争

两个线程可以同时调用它并最终创建PinballSimulator对象的两个副本,这违反了代表不变量。

假设两个线程正在运行getInstance()。
对于两个线程正在执行的每对可能的行号,是否有可能违反不变量?
全局静态变量不会自动受到线程监禁。

如果你的程序中有静态变量,那么你必须提出一个论点,即只有一个线程会使用它们,并且你必须清楚地记录这个事实。 [在代码中记录 - 第4章]

更好的是,你应该完全消除静态变量。

isPrime()方法从多个线程调用并不安全,其客户端甚至可能不会意识到它。

原因是静态变量缓存引用的HashMap被所有对isPrime()的调用共享,并且HashMap不是线程安全的。

如果多个线程同时通过调用cache.put()来改变地图,那么地图可能会以与上一次读数中的银行账户损坏相同的方式被破坏。

如果幸运的话,破坏可能会导致哈希映射深处发生异常,如NullPointerException或IndexOutOfBoundsException。

但它也可能会悄悄地给出错误的答案。

策略2:不可变性

实现线程安全的第二种方法是使用不可变引用和数据类型。使用不可变数据类型和不可变引用,避免多线程之间的竞争条件

不变性解决竞争条件的共享可变数据原因,并简单地通过使共享数据不可变来解决它。

final变量是不可变的引用,所以声明为final的变量可以安全地从多个线程访问。

你只能读取变量,而不能写入变量。

因为这种安全性只适用于变量本身,我们仍然必须争辩变量指向的对象是不可变的。

不可变对象通常也是线程安全的。不可变数据通常是线程安全的
我们说“通常”,因为不可变性的当前定义对于并发编程而言过于松散。

如果一个类型的对象在整个生命周期中始终表示相同的抽象值,则类型是不可变的。

但实际上,只要这些突变对于客户是不可见的,例如有益的突变(参见3.3章节),实际上允许类型自由地改变其代表。

如缓存,延迟计算和数据结构重新平衡

对于并发性,这种隐藏的变异是不安全的。

使用有益突变的不可变数据类型必须使用锁使自己线程安全。如果ADT中使用了有益突变,必须要通过“加锁”机制来保证线程安全

更强的不变性定义

为了确信一个不可变的数据类型是没有锁的线程安全的,我们需要更强的不变性定义:

没有变值器方法

所有字段都是私人的和最终的

没有表示风险

表示中的可变对象没有任何突变

甚至不能是有益的突变

如果你遵循这些规则,那么你可以确信你的不可变类型也是线程安全的。
不要提供“setter”方法 - 修改字段引用的字段或对象的方法。
使所有字段最终和私有。
不要让子类重写方法。

最简单的方法是将类声明为final。

更复杂的方法是使构造函数保持私有状态,并使用工厂方法构造实例。

如果实例字段包含对可变对象的引用,请不要允许更改这些对象:

不要提供修改可变对象的方法。

不要共享对可变对象的引用。

不要存储对传递给构造函数的外部可变对象的引用;如有必要,创建副本,并存储对副本的引用。

同样,必要时创建内部可变对象的副本,以避免在方法中返回原件。

策略3:使用线程安全数据类型

实现线程安全的第三个主要策略是将共享可变数据存储在现有的线程数据类型中。 如果必须要用mutable的数据类型在多线程之间共享数据,则要使用线程安全的数据类型。

当Java库中的数据类型是线程安全的时,其文档将明确说明这一事实。在JDK中的类,文档中明确指明了是否线程

一般来说,JDK同时提供两个相同功能的类,一个是线程安全的,另一个不是。线程安全的类一般性能上受影响

原因是这个报价表明:与不安全类型相比,线程安全数据类型通常会导致性能损失。

线程安全集合

Java中的集合接口

列表,设置,地图

具有不是线程安全的基本实现。集合类都是线程不安全的

ArrayList,HashMap和HashSet的实现不能从多个线程安全地使用。

Collections API提供了一组包装器方法来使集合线程安全,同时仍然可变。 Java API提供了进一步的装饰器

这些包装器有效地使集合的每个方法相对于其他方法是原子的。

原子动作一次有效地发生

它不会将其内部操作与其他操作的内部操作交错,并且在整个操作完成之前,操作的任何效果都不会被其他线程看到,因此它从未部分完成。

线程安全包装

public static Collection synchronizedCollection(Collection c);
public static Set synchronizedSet(Set s);
public static List synchronizedList(List list);
public static Map synchronizedMap(Map m);
public static SortedSet synchronizedSortedSet(SortedSet s);
public static SortedMap synchronizedSortedMap(SortedMap m);

包装实现将他们所有的实际工作委托给指定的集合,但在集合提供的基础上添加额外的功能。
这是装饰者模式的一个例子(参见5-3节)
这些实现是匿名的;该库不提供公共类,而是提供静态工厂方法。
所有这些实现都可以在Collections类中找到,该类仅由静态方法组成。
同步包装将自动同步(线程安全)添加到任意集合。

不要绕开包装

确保抛弃对底层非线程安全集合的引用,并仅通过同步包装来访问它。
新的HashMap只传递给synchronizedMap(),并且永远不会存储在其他地方。
底层的集合仍然是可变的,引用它的代码可以规避不变性。
在使用synchronizedMap(hashMap)之后,不要再参数hashMap共享给其他线程,不要保留别名,一定要彻底销毁

迭代器仍然不是线程安全的

尽管方法调用集合本身(get(),put(),add()等)现在是线程安全的,但从集合创建的迭代器仍然不是线程安全的。 即使在线程安全的集合类上,使用迭代器也是不安全的
此迭代问题的解决方案将是在需要迭代它时获取集合的锁。除非使用锁机制

原子操作不足以阻止竞争

您使用同步收集的方式仍可能存在竞争条件。
考虑这个代码,它检查列表是否至少有一个元素,然后获取该元素:
即使您将list放入同步列表中,此代码仍可能存在竞争条件,因为另一个线程可能会删除isEmpty()调用和get()调用之间的元素。

同步映射确保containsKey(),get()和put()现在是原子的,所以从多个线程使用它们不会损害映射的rep不变量。
但是这三个操作现在可以以任意方式相互交织,这可能会破坏缓存中需要的不变量:如果缓存将整数x映射到值f,那么当且仅当f为真时x是素数。
如果缓存永远失败这个不变量,那么我们可能会返回错误的结果。

注意

我们必须争论containsKey(),get()和put()之间的竞争不会威胁到这个不变量。

containsKey()和get()之间的竞争是无害的,因为我们从不从缓存中删除项目 - 一旦它包含x的结果,它将继续这样做。

containsKey()和put()之间存在竞争。 因此,最终可能会有两个线程同时测试同一个x的初始值,并且两个线程都会调用put()与答案。 但是他们都应该用相同的答案来调用put(),所以无论哪个人赢得比赛并不重要 - 结果将是相同的。

......在注释中自证线程
需要对安全性进行这种仔细的论证 - 即使在使用线程安全数据类型时 - 也是并发性很难的主要原因。

一个简短的总结
通过共享可变数据的竞争条件实现安全的三种主要方式:

禁闭:不共享数据。

不变性:共享,但保持数据不变。

线程安全数据类型:将共享的可变数据存储在单个线程安全数据类型中。

减少错误保证安全。

我们正试图消除一大类并发错误,竞争条件,并通过设计消除它们,而不仅仅是意外的时间。

容易明白。

应用这些通用的,简单的设计模式比关于哪种线程交叉是可能的而哪些不可行的复杂论证更容易理解。

准备好改变。

我们在一个线程安全参数中明确地写下这些理由,以便维护程序员知道代码依赖于线程安全。

Strategy 4: Locks and Synchronization

最复杂也最有价值的threadsafe策略

回顾
数据类型或函数的线程安全性:在从多个线程使用时行为正确,无论这些线程如何执行,无需额外协调。线程安全不应依赖于偶然

原理:并发程序的正确性不应该依赖于时间事件。

有四种策略可以使代码安全并发:

监禁:不要在线程之间共享数据。

不变性:使共享数据不可变。

使用现有的线程安全数据类型:使用为您协调的数据类型。

前三种策略的核心思想:

避免共享→即使共享,也只能读/不可写(immutable)→即使可写(mutable),共享的可写数据应该自己具备在多线程之间协调的能力,即“使用线程安全的mutable ADT”

同步和锁定

由于共享可变数据的并发操作导致的竞争条件是灾难性的错误 - 难以发现,重现和调试 - 我们需要一种共享内存的并发模块以实现彼此同步的方式。
很多时候,无法满足上述三个条件...
使代码安全并发的第四个策略是:

同步和锁:防止线程同时访问共享数据。

程序员来负责多线程之间对可变数据的共享操作,通过“同步”策略,避免多线程同时访问数据

锁是一种同步技术。

锁是一种抽象,最多允许一个线程拥有它。保持锁定是一条线程告诉其他现成:“我正在改变这个东西,现在不要触摸它。”

使用锁机制,获得对数据的独家改变权,其他线程被阻塞,不得访问

使用锁可以告诉编译器和处理器你正在同时使用共享内存,所以寄存器和缓存将被刷新到共享存储,确保锁的所有者始终查看最新的数据。
阻塞一般意味着一个线程等待(不再继续工作)直到事件发生。

两种锁定操作

acquire允许线程获取锁的所有权。

如果一个线程试图获取当前由另一个线程拥有的锁,它会阻塞,直到另一个线程释放该锁。

在这一点上,它将与任何其他尝试获取锁的线程竞争。

一次只能有一个线程拥有该锁。

release放弃锁的所有权,允许另一个线程获得它的所有权。

如果另一个线程(如线程2)持有锁l,线程1上的获取(l)将会阻塞。它等待的事件是线程2执行释放(l)。

此时,如果线程1可以获取l,则它继续运行其代码,并拥有锁的所有权。

另一个线程(如线程3)也可能在获取(l)时被阻塞。线程1或3将采取锁定并继续。另一个将继续阻塞,再次等待释放(l)。

(1)同步块和方法

锁定

锁是如此常用以至于Java将它们作为内置语言功能提供。锁是Java的语言提供的内嵌机制

每个对象都有一个隐式关联的锁 - 一个String,一个数组,一个ArrayList,每个类及其所有实例都有一个锁。

即使是一个不起眼的Object也有一个锁,因此裸露的Object通常用于显式锁定:

但是,您不能在Java的内部锁上调用acquire和release。 而是使用synchronized语句来获取语句块持续时间内的锁定:
像这样的同步区域提供互斥性:一次只能有一个线程处于由给定对象的锁保护的同步区域中。
换句话说,你回到了顺序编程世界,一次只运行一个线程,至少就其他同步区域而言,它们指向同一个对象。

锁定保护对数据的访问

锁用于保护共享数据变量。锁保护共享数据

如果所有对数据变量的访问都被相同的锁对象保护(被同步块包围),那么这些访问将被保证为原子 - 不被其他线程中断。

使用以下命令获取与对象obj关联的锁定:
synchronized(obj){...}

它阻止其他线程进入synchronized(obj)直到线程t完成其同步块为止。

锁只与其他获取相同锁的线程相互排斥。 所有对数据变量的访问必须由相同的锁保护。 注意:要互斥,必须使用同一个锁进行保护

你可以在单个锁后面保护整个变量集合,但是所有模块必须同意他们将获得并释放哪个锁。

监视器模式

在编写类的方法时,最方便的锁是对象实例本身,即this。用ADT自己做锁
作为一种简单的方法,我们可以通过在synchronized(this)内包装所有对rep的访问来守护整个类的表示。
监视器模式:监视器是一个类,它们的方法是互斥的,所以一次只能有一个线程在类的实例中。
每一个触及表示的方法都必须用锁来保护,甚至像length()和toString()这样的显而易见的小代码。
这是因为必须保护读取以及写入 - 如果读取未被保留,则他们可能能够看到处于部分修改状态的rep。
如果将关键字synchronized添加到方法签名中,Java将像您在方法主体周围编写synchronized(this)一样操作。

同步方法

同一对象上的同步方法的两次调用不可能交错。对同步的方法,多个线程执行它时不允许交错,也就是说“按原子的串行方式执行”

当一个线程正在为一个对象执行一个同步方法时,所有其他调用同一对象的同步方法的线程将阻塞(暂停执行),直到第一个线程完成对象。

当一个同步方法退出时,它会自动建立与同一对象的同步方法的任何后续调用之间的发生前关系。

这保证对所有线程都可见对象状态的更改。

同步语句/块

同步方法和同步(this)块之间有什么区别?

与synchronized方法不同,synchronized语句必须指定提供内部锁的对象。

同步语句对于通过细粒度同步来提高并发性非常有用。

二者有何区别?

后者需要显式的给出锁,且不一定非要是this

后者可提供更细粒度的并发控制

锁定规则

锁定规则是确保同步代码是线程安全的策略。
我们必须满足两个条件:

每个共享的可变变量必须由某个锁保护。除了在获取该锁的同步块内,数据可能不会被读取或写入。任何共享的可变变量/对象必须被锁所保护

如果一个不变量涉及多个共享的可变变量(它甚至可能在不同的对象中),那么涉及的所有变量都必须由相同的锁保护。一旦线程获得锁定,必须在释放锁定之前重新建立不变量。涉及到多个mutable变量的时候,它们必须被一个锁所保护

这里使用的监视器模式满足这两个规则。代表中所有共享的可变数据 - 代表不变量依赖于 - 都被相同的锁保护。

发生-前关系

这种发生-前关系,只是保证多个线程共享的对象通过一个特定语句写入的内容对另一个读取同一对象的特定语句是可见的。
这是为了确保内存一致性。
发生-前关系(a→ b)是两个事件的结果之间的关系,因此如果在事件发生之前发生一个事件,那么结果必须反映出,即使这些事件实际上是无序执行的。

这涉及基于并发系统中的事件对的潜在因果关系对事件进行排序。

它由Leslie Lamport制定。

正式定义为事件中最不严格的部分顺序,以便:

如果事件a和b在同一个过程中发生,如果在事件b发生之前发生了事件a则a→b;

如果事件a是发送消息,并且事件b是在事件a中发送的消息的接收,则a→b。

像所有严格的偏序一样,发生-前关系是传递的,非自反的和反对称的。

原子数据访问的关键字volatile

使用volatile(不稳定)变量可降低内存一致性错误的风险,因为任何对volatile变量的写入都会在后续读取该变量的同时建立happen-before关系。
这意味着对其他线程总是可见的对volatile变量的更改。
更重要的是,这也意味着当一个线程读取一个volatile变量时,它不仅会看到volatile的最新变化,还会看到导致变化的代码的副作用。
这是一个轻量级同步机制。
使用简单的原子变量访问比通过同步代码访问这些变量更有效,但需要程序员更多的关注以避免内存一致性错误。

(3)到处使用同步?

那么线程安全是否只需将synchronized关键字放在程序中的每个方法上?
不幸的是,
首先,你实际上并不想同步方法。

同步对您的程序造成很大的损失。 同步机制给性能带来极大影响

由于需要获取锁(并刷新高速缓存并与其他处理器通信),因此进行同步方法调用可能需要更长的时间。

由于这些性能原因,Java会将许多可变数据类型默认为不同步。当你不需要同步时,不要使用它。除非必要,否则不要用.Java中很多mutable的类型都不是threadsafe就是这个原因

另一个以更慎重的方式使用同步的理由是,它最大限度地减少了访问锁的范围。尽可能减小锁的范围

为每个方法添加同步意味着你的锁是对象本身,并且每个引用了你的对象的客户端都会自动引用你的锁,它可以随意获取和释放。

您的线程安全机制因此是公开的,可能会受到客户的干扰。

与使用作为表示内部对象的锁并使用synchronized()块适当并节省地获取相比。
最后,到处使用同步并不够实际。

在没有思考的情况下同步到一个方法上意味着你正在获取一个锁,而不考虑它是哪个锁,或者是否它是保护你将要执行的共享数据访问的正确锁。

假设我们试图通过简单地将synchronized同步到它的声明来解决findReplace的同步问题:
public static synchronized boolean findReplace(EditBuffer buf, ...)

它确实会获得一个锁 - 因为findReplace是一个静态方法,它将获取findReplace恰好处于的整个类的静态锁定,而不是实例对象锁定。

结果,一次只有一个线程可以调用findReplace - 即使其他线程想要在不同的缓冲区上运行,这些缓冲区应该是安全的,它们仍然会被阻塞,直到单个锁被释放。所以我们会遭受重大的性能损失。

synchronized关键字不是万能的。
线程安全需要一个规范 - 使用监禁,不变性或锁来保护共享数据。
这个纪律需要被写下来,否则维护人员不会知道它是什么。

Synchronized不是灵丹妙药,你的程序需要严格遵守设计原则,先试试其他办法,实在做不到再考虑lock。
所有关于线程的设计决策也都要在ADT中记录下来。

(4)活性:死锁,饥饿和活锁

活性
并发应用程序的及时执行能力被称为活跃性。
三个子度量标准:

死锁

饥饿

活锁

(1)死锁

如果使用得当,小心,锁可以防止竞争状况。
但是接下来的另一个问题就是丑陋的头脑。
由于使用锁需要线程等待(当另一个线程持有锁时获取块),因此可能会陷入两个线程正在等待对方的情况 - 因此都无法取得进展。
死锁描述了两个或更多线程永远被阻塞的情况,等待对方。
死锁:多个线程竞争锁,相互等待对方释放锁
当并发模块卡住等待对方执行某些操作时发生死锁。
死锁可能涉及两个以上的模块:死锁的信号特征是依赖关系的一个循环,例如, A正在等待B正在等待C正在等待A,它们都没有取得进展。
死锁的丑陋之处在于它
线程安全的锁定方法非常强大,但是(与监禁和不可变性不同)它将阻塞引入程序。
线程必须等待其他线程退出同步区域才能继续。
在锁定的情况下,当线程同时获取多个锁时会发生死锁,并且两个线程最终被阻塞,同时持有锁,每个锁都等待另一个锁释放。
不幸的是,监视器模式使得这很容易做到。

死锁:

线程A获取harry锁(因为friend方法是同步的)。

然后线程B获取snape上的锁(出于同样的原因)。

他们都独立地更新他们各自的代表,然后尝试在另一个对象上调用friend() - 这要求他们获取另一个对象上的锁。

所以A正在拿着哈利等着斯内普,而B正拿着斯内普等着哈利。

两个线程都卡在friend()中,所以都不会管理退出同步区域并将锁释放到另一个区域。

这是一个经典的致命的拥抱。 该程序停止。

问题的实质是获取多个锁,并在等待另一个锁释放时持有某些锁。

死锁解决方案1:锁定顺序

对需要同时获取的锁定进行排序,并确保所有代码按照该顺序获取锁定。

在示例中,我们可能总是按照向导的名称按字母顺序获取向导对象上的锁定。

虽然锁定顺序很有用(特别是在操作系统内核等代码中),但它在实践中有许多缺点。
首先,它不是模块化的 - 代码必须知道系统中的所有锁,或者至少在其子系统中。
其次,代码在获取第一个锁之前可能很难或不可能确切知道它需要哪些锁。 它可能需要做一些计算来弄清楚。

例如,想一想在社交网络图上进行深度优先搜索,在你开始寻找它们之前,你怎么知道哪些节点需要被锁定?

死锁解决方案2:粗略锁定

要使用粗略锁定 - 使用单个锁来防止许多对象实例,甚至是程序的整个子系统。

例如,我们可能对整个社交网络拥有一个锁,并且对其任何组成部分的所有操作都在该锁上进行同步。

在代码中,所有的巫师都属于一个城堡,我们只是使用该Castle对象的锁来进行同步。

但是,它有明显的性能损失。

如果你用一个锁保护大量可变数据,那么你就放弃了同时访问任何数据的能力。

在最糟糕的情况下,使用单个锁来保护所有内容,您的程序可能基本上是顺序的。

(2)饥饿

饥饿描述了线程无法获得对共享资源的定期访问并且无法取得进展的情况。

当共享资源被“贪婪”线程长时间停用时,会发生这种情况。

例如,假设一个对象提供了一个经常需要很长时间才能返回的同步方法。

如果一个线程频繁地调用此方法,那么其他线程也需要经常同步访问同一对象。

因为其他线程锁时间太长,一个线程长时间无法获取其所需的资源访问权(锁),导致无法往下进行。

(3)活锁

线程通常会响应另一个线程的动作而行动。
如果另一个线程的动作也是对另一个线程动作的响应,则可能导致活锁。
与死锁一样,活锁线程无法取得进一步进展。
但是,线程并未被阻止 - 他们只是忙于响应对方恢复工作。
这与两个试图在走廊上相互传递的人相当:

阿尔方塞向左移动让加斯顿通过,而加斯东向右移动让阿尔方塞通过。

看到他们仍然互相阻拦,阿尔方塞向右移动,而加斯东向左移动。他们仍然互相阻拦,所以......

(5)wait(),notify()和notifyAll()

保护块

防护区块:这样的区块首先轮询一个必须为真的条件才能继续。
假设,例如guardedJoy是一种方法,除非另一个线程设置了共享变量joy,否则该方法不能继续。

这种方法可以简单地循环直到满足条件,但是该循环是浪费的,因为它在等待时连续执行。 某些条件未得到满足,所以一直在空循环检测,直到条件被满足。这是极大浪费。

wait(),notify()和notifyAll()

以下是针对任意Java对象o定义的:

o.wait():释放o上的锁,进入o的等待队列并等待

o.notify():唤醒o的等待队列中的一个线程

o.notifyAll():唤醒o的等待队列中的所有线程

Object.wait()

Object.wait()会导致当前线程等待,直到另一个线程调用此对象的notify()方法或notifyAll()方法。换句话说,这个方法的行为就好像它只是执行调用wait(0)一样。该操作使对象所处的阻塞/等待状态,直到其他线程调用该对象的notify()操作

Object.notify()/ notifyAll()

Object.notify()唤醒正在等待该对象监视器的单个线程。如果任何线程正在等待这个对象,则选择其中一个线程来唤醒。随机选择一个在该对象上调用等方法的线程,解除其阻塞状态

线程通过调用其中一个等待方法在对象的监视器上等待。

在当前线程放弃对该对象的锁定之前,唤醒的线程将无法继续。

唤醒的线程将以通常的方式与其他可能正在主动竞争的线程竞争对该对象进行同步;例如,被唤醒的线程在作为下一个线程来锁定这个对象时没有可靠的特权或缺点。

此方法只应由作为此对象监视器所有者的线程调用。
线程以三种方式之一成为对象监视器的所有者:

通过执行该对象的同步实例方法。

通过执行同步对象的同步语句的主体。

对于Class类型的对象,通过执行该类的同步静态方法。

在守卫块中使用wait()

wait()的调用不会返回,直到另一个线程发出某个特殊事件可能发生的通知 - 尽管不一定是该线程正在等待的事件。
Object.wait()会导致当前线程等待,直到另一个线程调用此对象的notify()方法或notifyAll()方法。
当wait()被调用时,线程释放锁并暂停执行。
在将来的某个时间,另一个线程将获得相同的锁并调用Object.notifyAll(),通知所有等待该锁的线程发生重要事件:
第二个线程释放锁定一段时间后,第一个线程重新获取锁定,并通过从等待的调用返回来恢复。

wait(),notify()和notifyAll()
调用对象o的方法的线程通常必须预先锁定o:

如何制定安全性论据

回想一下:开发ADT的步骤
指定:定义操作(方法签名和规约)。
测试:开发操作的测试用例。测试套件包含基于对操作的参数空间进行分区的测试策略。
代表:选择一个代表。

首先实现一个简单的,强大的代表。

写下rep不变和抽象函数,并实现checkRep(),它在每个构造函数,生成器和增量器方法的末尾声明了rep不变量。

+++同步

说出你的代表是线程安全的。

在你的类中作为注释明确地写下来,直接用rep不变量表示,以便维护者知道你是如何为类设计线程安全性的。

做一个安全论证

并发性很难测试和调试!
所以如果你想让自己和别人相信你的并发程序是正确的,最好的方法是明确地说明它没有竞争,并且记下来。在代码中注释的形式增加说明:该ADT采取了什么设计决策来保证线程安全

安全性参数需要对模块或程序中存在的所有线程及其使用的数据进行编目,并针对您使用的四种技术中的哪一种来防止每个数据对象或变量的竞争:监禁,不可变性,线程安全数据类型或同步。采取了四种方法中的哪一种?

当你使用最后两个时,你还需要争辩说,对数据的所有访问都是适当的原子

也就是说,你所依赖的不变量不受交织威胁。如果是后两种,还需考虑对数据的访问都是原子的,不存在交错

用于监禁的线程安全论证

因为您必须知道系统中存在哪些线程以及他们有权访问哪些对象,因此在我们仅就数据类型进行争论时,监禁通常不是一种选择。 除非你知道线程访问的所有数据,否则Confinement无法彻底保证线程安全

如果数据类型创建了自己的一组线程,那么您可以讨论关于这些线程的监禁。

否则,线程从外部进入,携带客户端调用,并且数据类型可能无法保证哪些线程具有对什么的引用。

因此,在这种情况下,Confinement不是一个有用的论证。

通常我们在更高层次使用约束,讨论整个系统,并论证为什么我们不需要线程安全的某些模块或数据类型,因为它们不会通过设计在线程间共享。除非是在ADT内部创建的线程,可以清楚得知访问数据有哪些

总结

并发程序设计的目标

并发程序是否可以避免bug?

我们关心三个属性:

安全。 并发程序是否满足其不变量和规约? 访问可变数据的竞争会威胁到安全。 安全问题:你能证明一些不好的事情从未发生过?

活性。 程序是否继续运行,并最终做你想做的事情,还是会陷入永远等待事件永远不会发生的地方? 你能证明最终会发生什么好事吗? 死锁威胁到活性。

公平。 并发模块具有处理能力以在计算上取得进展。 公平主要是OS线程调度器的问题,但是你可以通过设置线程优先级来影响它。

实践中的并发

在真正的项目中通常采用什么策略?

库数据结构不使用同步(为单线程客户端提供高性能,同时让多线程客户端在顶层添加锁定)或监视器模式。

具有许多部分的可变数据结构通常使用粗粒锁定或线程约束。大多数图形用户界面工具包遵循以下方法之一,因为图形用户界面基本上是一个可变对象的大型可变树。 Java Swing,图形用户界面工具包,使用线程约束。只有一个专用线程被允许访问Swing的树。其他线程必须将消息传递到该专用线程才能访问该树。

安全失败带来虚假的安全感。生存失败迫使你面对错误。有利于活跃而不是安全的诱惑。

搜索通常使用不可变的数据类型。多线程很容易,因为涉及的所有数据类型都是不可变的。不会有竞争或死锁的风险。

操作系统通常使用细粒度的锁来获得高性能,并使用锁定顺序来处理死锁问题。

数据库使用与同步区域类似的事务来避免竞争条件,因为它们的影响是原子的,但它们不必获取锁定,尽管事务可能会失败并在事件发生时被回滚。数据库还可以管理锁,并自动处理锁定顺序。将在数据库系统课程中介绍。

总结

生成一个安全无漏洞,易于理解和可以随时更改的并发程序需要仔细思考。

只要你尝试将它们固定下来,Heisenbugs就会消失,所以调试根本不是实现正确线程安全代码的有效方法。

线程可以以许多不同的方式交错操作,即使是所有可能执行的一小部分,也永远无法测试。

创建关于数据类型的线程安全参数,并在代码中记录它们。
获取一个锁允许一个线程独占访问该锁保护的数据,强制其他线程阻塞 - 只要这些线程也试图获取同一个锁。
监视器使用通过每种方法获取的单个锁来引用数据类型的代表。
获取多个锁造成的阻塞会造成死锁的可能性。
什么是并发编程?
进程,线程和时间片
交织和竞争条件
线程安全

战略1:监禁

策略2:不可变性

策略3:使用线程安全数据类型

策略4:锁定和同步

如何做安全论证

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/71343.html

相关文章

  • 线程基础必要知识点!看了学习多线程事半功倍

    摘要:是需要我们去处理很多事情,为了防止多线程给我们带来的安全和性能的问题下面就来简单总结一下我们需要哪些知识点来解决多线程遇到的问题。 前言 不小心就鸽了几天没有更新了,这个星期回家咯。在学校的日子要努力一点才行! 只有光头才能变强 回顾前面: 多线程三分钟就可以入个门了! Thread源码剖析 本文章的知识主要参考《Java并发编程实战》这本书的前4章,这本书的前4章都是讲解并发的基...

    YPHP 评论0 收藏0
  • 初读《Java并发编程的艺术》-:Executor框架 -10.1 Executor框架简介

    摘要:线程的启动与销毁都与本地线程同步。操作系统会调度所有线程并将它们分配给可用的。框架的成员主要成员线程池接口接口接口以及工具类。创建单个线程的接口与其实现类用于表示异步计算的结果。参考书籍并发编程的艺术方腾飞魏鹏程晓明著 在java中,直接使用线程来异步的执行任务,线程的每次创建与销毁需要一定的计算机资源开销。每个任务创建一个线程的话,当任务数量多的时候,则对应的创建销毁开销会消耗大量...

    aisuhua 评论0 收藏0
  • 并发编程安全问题:可见性、原子性有序性

    摘要:线程切换带来的原子性问题我们把一个或者多个操作在执行的过程中不被中断的特性称为原子性。编译优化带来的有序性问题顾名思义,有序性指的是程序按照代码的先后顺序执行。 缓存导致的可见性问题 一个线程对共享变量的修改,另外一个线程能够立刻看到,称为可见性 在多核下,多个线程同时修改一个共享变量时,如++操作,每个线程操作的CPU缓存写入内存的时机是不确定的。除非你调用CPU相关指令强刷。 sh...

    pcChao 评论0 收藏0
  • Effective Java 三版 全文翻译

    摘要:本章中的大部分内容适用于构造函数和方法。第项其他方法优先于序列化第项谨慎地实现接口第项考虑使用自定义的序列化形式第项保护性地编写方法第项对于实例控制,枚举类型优先于第项考虑用序列化代理代替序列化实例附录与第版中项目的对应关系参考文献 effective-java-third-edition 介绍 Effective Java 第三版全文翻译,纯属个人业余翻译,不合理的地方,望指正,感激...

    galois 评论0 收藏0
  • 《Java并发编程实战》读书笔记-1 简介

    摘要:线程允许同一个进程中同时存在多个程序控制流。线程也被称为轻量级进程。现代操作系统中,都是以线程为基本的调度单位,而不是进程。 并发简史 在早期的计算机中不包含操作系统,从头至尾都只执行一个程序,并且这个程序能访问计算机所有资源。操作系统的出现使得计算机每次能运行多个程序,并且不同的程序都在单独的进程中运行:操作系统为各个独立执行的进程分配内存、文件句柄、安全证书等。不同进程之间通过一些...

    zhoutk 评论0 收藏0

发表评论

0条评论

instein

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<