詳解 Java 并發(fā)流程控制工具一文補充說明
本文將針對JUC包下幾個常見的工具類進行深入剖析和演示,通過針對本文的閱讀,讀者將會對JUC包下的工具有一個全面的了解和運用。
一、CountDownLatch(倒計時門閂)
1. CountDownLatch簡介
在并發(fā)編程的禪意中,CountDownLatch本質上就是一種閉鎖,而閉鎖的語義則是等待所有其他活動都完成了,才會繼續(xù)執(zhí)行后續(xù)的操作。
筆者一般稱CountDownLatch為倒計時門閂,它主要用于需要某些條件下才能喚醒的需求場景,例如我們線程1必須等到線程2做完某些事,那么就可以設置一個CountDownLatch并將數值設置為1,一旦線程2完成業(yè)務邏輯后,將數值修改為0,此時線程1就會被喚醒:
2. 基于CountDownLatch實現等待多線程就緒
通過上述的描述可能有點抽象,我們直接通過幾個例子演示一下,我們現在有這樣一個需求,希望等待5個線程完成之后,打印輸出一句工作完成:
對應的代碼示例如下,可以看到我們創(chuàng)建了數值為5的CountDownLatch ,一旦線程池里的線程完成工作后就調用countDown進行扣減,一旦數值變?yōu)?,主線程await就會放行,執(zhí)行后續(xù)輸出:
int workerSize = 5;
CountDownLatch workCount = new CountDownLatch(workerSize);
ExecutorService threadPool = Executors.newFixedThreadPool(workerSize);
for (int i = 0; i < workerSize; i++) {
final int workerNum = i;
//5個工人輸出完成工作后,扣減倒計時門閂數
threadPool.submit(() -> {
log.info("worker[{}]完成手頭的工作", workerNum);
workCount.countDown();
});
}
try {
//阻塞當前線程(主線程)往后走,只有倒計時門閂變?yōu)?之后才能繼續(xù)后續(xù)邏輯
log.info("等待worker工作完成");
workCount.await();
} catch (InterruptedException e) {
log.info("倒計時門閂阻塞失敗,失敗原因[{}]", e.getMessage(), e);
}
threadPool.shutdown();
while (!threadPool.isTerminated()) {
}
log.info("所有工人都完成手頭的工作了");
對應的我們也給出輸出結果,可以看到主線程在線程池線程完成后才輸出:
3. 基于CountDownLatch實現運動員賽跑
實際上CountDownLatch可以讓多個線程進行等待,我們不妨用線程模擬一下所有運動員就緒后,等待槍響后起跑的場景:
代碼如下,每當運動員即線程池的線程準備就緒,則調用await等待槍響,一旦所有運動員就緒之后,主線程調用countDown模擬槍響,然后運動員起跑:
Console.log("百米跑比賽開始");
int playerNum = 3;
CountDownLatch gun = new CountDownLatch(1);
ExecutorService threadPool = Executors.newFixedThreadPool(playerNum);
for (int i = 0; i < playerNum; i++) {
final int playNo = i;
threadPool.submit(() -> {
Console.log("[{}]號運動員已就緒", playNo);
try {
gun.await();
} catch (InterruptedException e) {
Console.log("[{}]號運動員線程阻塞失敗,失敗原因[{}]", playNo, e.getMessage(), e);
}
Console.log("[{}]號運動員已經到達終點", playNo);
});
}
//按下槍 所有運動員起跑
gun.countDown();
threadPool.shutdown();
while (!threadPool.isTerminated()) {
}
Console.log("百米賽跑已結束");
對應的我們也給出相應的輸出結果:
百米跑比賽開始
[0]號運動員已就緒
[2]號運動員已就緒
[1]號運動員已就緒
[2]號運動員已經到達終點
[0]號運動員已經到達終點
[1]號運動員已經到達終點
百米賽跑已結束
4. 從源碼角度分析CountDownLatch工作流程
我們以等待所有工人完成工作的例子進行解析,實際上在CountDownLatch是通過state和一個抽象隊列即aqs完成多線程之間的流程調度,主線程調用await方法等待其他worker線程,如果其它worker線程沒有完成工作,那么CountDownLatch就會將其存入抽象隊列中。
一旦其他線程將state設置為0時,await對應的線程就會從抽象隊列中釋放并喚醒:
對應我們給出countDown的實現,可以看到該方法底層就是將aqs隊列中的state進行扣減:
public void countDown() {
sync.releaseShared(1);
}
//releaseShared內部核心邏輯就是將state扣減1
protected boolean tryReleaseShared(int releases) {
// Decrement count; signal when transition to zero
for (;;) {
int c = getState();
if (c == 0)
return false;
//扣減state并通過cas修改賦值
int nextc = c-1;
if (compareAndSetState(c, nextc))
return nextc == 0;
}
}
而countDown本質上就是查看這個state,如果state被扣減為0,則調用aqs底層doReleaseShared方法將隊列中等待線程喚醒:
public void countDown() {
sync.releaseShared(1);
}
public final boolean releaseShared(int arg) {
//查看是否扣減為0
if (tryReleaseShared(arg)) {
//如果是0則將當前等待線程喚醒
doReleaseShared();
return true;
}
return false;
}
上文講解countDown涉及一些關于AQS的實用理解和設計,關于更多AQS的知識點,感興趣的讀者可以閱讀一下筆者的這篇文章:《AQS 源碼解析:原理與實踐》。
二、Semaphore(信號量)
1. 詳解Semaphore
信號量多用于限流的場景,例如我們希望單位時間內只能有一個線程工作,我們就可以使用信號量,只有拿到線程的信號量才能工作,工作完成后釋放信號量,其余線程才能爭搶這個信號量并進行進一步的操作。
對應我們給出下面這段代碼,可以看到筆者聲明信號量數值為6,每當線程拿到3個信號量之后就會執(zhí)行業(yè)務操作,完成后調用release釋放3個令牌,讓其他線程繼續(xù)爭搶:
//設置可復用的信號量,令牌數為3
Semaphore semaphore = new Semaphore(6, true);
//創(chuàng)建5個線程
int workSize = 5;
ExecutorService executorService = Executors.newFixedThreadPool(workSize);
for (int i = 0; i < workSize; i++) {
executorService.submit(() -> {
try {
//拿3個令牌
semaphore.acquire(3);
log.info("進行業(yè)務邏輯處理.......");
ThreadUtil.sleep(1000);
//釋放3個令牌
semaphore.release(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
executorService.shutdown();
while (!executorService.isTerminated()) {
}
對應輸出結果如下,可以看到每個線程拿到令牌后都會休眠1秒,從輸出結果來看每秒只有兩個線程才工作,符合我們的限流需求:
2. 詳解Semaphore工作原理
Semaphore底層也是用到的aqs隊列,線程進行資源獲取時也是通過查看state是否足夠,在明確足夠的情況下進行state扣減,然后進行工作。如果線程發(fā)現state數量不夠,那么就會被Semaphore存入aqs底層的抽象隊列中,直到state數量足夠后被喚醒:
對此我們給出Semaphore底層的acquire的邏輯可以看到,它會讀取state數值然后進行扣減,如果剩余數量大于0則說明令牌獲取成功線程可以執(zhí)行后續(xù)邏輯,反之說明當前令牌數不夠,外部邏輯會將該線程掛到等待隊列中,等待令牌足夠后將其喚醒:
protected int tryAcquireShared(int acquires) {
for (;;) {
if (hasQueuedPredecessors())
return -1;
//讀取可用的state
int available = getState();
//計算剩余的state
int remaining = available - acquires;
//如果小于0說明令牌數不足直接返回出去,讓外部將線程掛起,反之通過cas修改剩余數,返回大于0的結果讓持有令牌的線程執(zhí)行后續(xù)邏輯
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
3. 基于Semaphore實現一個有界容器
利用Semaphore信號量并發(fā)獲取且資源循環(huán)可復用的特性,我們可以通過實例封閉技術落地一個有界的容器,如下代碼所示,只有得到信號量且添加成功了信號量才會成功扣減,如果沒有拿到信號量就阻塞無法添加,除非其他線程釋放自己的資源。
如下圖,筆者利用信號量實現一個列表容器的限流設置,可以看到當前容器還剩一個空間,所以信號量數也是1,當線程0獲得信號量成功后將元素24添加至容器中。隨后的線程1看到信號量為0,即知曉容器沒有可用空間就會被阻塞等待:
一旦線程1刪除一個元素成功后,就會歸還一個令牌,此時線程1就會被信號量喚醒,嘗試獲取令牌并添加元素,這就是我們有界容器實現的核心思路:
對應的我們給出有界容器的落地代碼示例:
public class BoundedList<E> {
private final List<E> list;
private final Semaphore semaphore;
/*
初始化一個并發(fā)的有界容器
*/
public BoundedList(int bound) {
this.list = Collections.synchronizedList(new ArrayList<>());
this.semaphore = new Semaphore(bound);
}
public boolean add(E element) {
boolean wasAdded = false;
try {
//獲取令牌,成功后才會添加容器
semaphore.acquire();
wasAdded = list.add(element);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//添加失敗則釋放令牌,讓其他線程可以嘗試到該有界容器中添加
if (!wasAdded)
semaphore.release();
return wasAdded;
}
}
public void remove(E element) {
boolean remove = false;
try {
remove = list.remove(element);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//只有明確元素移除成功,才會釋放令牌
if (remove)
semaphore.release();
}
}
@Override
public String toString() {
return JSONUtil.toJsonStr(list);
}
}
對應測試代碼如下,大體思路為:
- 嘗試讓線程0填滿容器使線程1阻塞
- 隨后線程0移除一個元素
- 線程1被喚醒,并成功獲取令牌,將元素5成功添加
BoundedList<Integer> list = new BoundedList<>(5);
CountDownLatch countDownLatch = new CountDownLatch(2);
new Thread(() -> {
//添加5個元素填滿容器
Console.log("線程1添加5個元素");
for (int i = 0; i < 5; i++) {
list.add(i);
}
ThreadUtil.sleep(5000);
//移除元素2,讓線程2添加元素5成功
Console.log("線程1移除元素2");
list.remove(2);
countDownLatch.countDown();
}).start();
new Thread(() -> {
ThreadUtil.sleep(1000);
Console.log("線程2添加元素5");
list.add(5);
Console.log("線程2添加元素5成功");
countDownLatch.countDown();
}).start();
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
Console.log("線程1和線程2執(zhí)行完畢,有界容器元素:{}", list);
輸出結果如下,符合我們對有界容器預期:
線程1添加5個元素
線程2添加元素5
線程1移除元素2
線程2添加元素5成功
線程1和線程2執(zhí)行完畢,有界容器元素:[0,1,3,4,5]
4. Semaphore使用注意事項
- 獲取和釋放的時候都可以指定數量,但是要保持一致。
- 公平性設置為true會更加合理
- 并不必須由獲取許可證的線程釋放許可證。可以是A獲取,B釋放。
三、Condition
1. 詳解Condition
Condition即條件對象,不是很常用或者直接用到的對象,常用于線程等待喚醒操作,例如A線程需要等待某個條件的時候,我們可以通過condition.await()方法,A線程就會進入阻塞狀態(tài)。
線程B執(zhí)行condition.signal()方法,則JVM就會從被阻塞線程中找到等待該condition的線程。線程A收到可執(zhí)行信號的時候,他的線程狀態(tài)就會變成Runnable可執(zhí)行狀態(tài)。
對此我們給出代碼示例,可以看到我們從ReentrantLock 中拿到一個Condition 對象,讓創(chuàng)建的線程進入等待狀態(tài),隨后讓主線程調用condition 的signal將其喚醒:
private ReentrantLock lock = new ReentrantLock();
//條件對象,操控線程的等待和通知
private Condition condition = lock.newCondition();
public void waitCondition() throws InterruptedException {
lock.lock();
try {
log.info("等待達到條件后通知");
condition.await();
log.info("收到通知,開始執(zhí)行業(yè)務邏輯");
} finally {
lock.unlock();
log.info("執(zhí)行完成,釋放鎖");
}
}
public void notifyCondition() throws InterruptedException {
lock.lock();
try {
log.info("達到條件發(fā)起通知");
condition.signal();
log.info("發(fā)起通知結束");
} finally {
lock.unlock();
log.info("發(fā)起通知執(zhí)行完成,釋放鎖");
}
}
public static void main(String[] args) throws InterruptedException {
Main obj = new Main();
new Thread(() -> {
try {
obj.waitCondition();
//讓出CPU時間片,交給主線程發(fā)起通知
Thread.sleep(3000);
} catch (InterruptedException e) {
log.error("等待條件通知設置失敗,失敗原因 [{}]", e.getMessage(), e);
}
}).start();
//休眠3s喚醒等待線程
Thread.sleep(3000);
obj.notifyCondition();
}
對應的我們也給出輸出結果:
2. 基于條件對象完成生產者、消費者模式
我們假設用一個隊列存放一波生產者生產的資源,當資源滿了通知消費者消費。當消費者消費空了,通知生產者生產。
所以這時候使用condition控制流程最合適(這也是阻塞的隊列內部的實現),所以我們要定義兩個信號,分別為:
- 當資源被耗盡,我們就使用資源未滿條件(notFull): 調用signal通知生產者消費,消費者調用await進入等待。
- 當資源被填滿,使用資源為空條件(notEmpty):將生產者用await方法掛起,消費者用signal喚醒消費告知非空。
很明顯生產者和消費者本質上就是基于這兩個標識分別標志自己的等待時機和通知時機,以生產者為例,即每生產一個資源后就可以調用notEmpty通知消費者消費,當生產者速度過快,則用await等待未滿notFull條件阻塞:
首先我們給出生產者和消費者條件和資源隊列聲明,基于上述條件我們給出一個經典的生產者和消費者模式的示例,我們首先給出生產者代碼,可以看到資源滿的時候調用notFull.await();將自己掛起等待未滿,生產資源后調用 notEmpty.signal();通知消費者消費。
對應消費者示例代碼也是一樣,當資源消費完全,調用notEmpty.await();等待不空,一旦消費定量資源調用notFull.signal();通知生產者生產。
最終代碼示例如下:
@Slf4j
public class ProducerMode {
//鎖
private static ReentrantLock lock = new ReentrantLock();
// 資源未滿
private Condition notFull = lock.newCondition();
//資源為空
private Condition notEmpty = lock.newCondition();
private Queue<Integer> queue = new PriorityQueue<>(10);
private int queueMaxSize = 10;
/**
* 生產者
*/
private class Producer extends Thread {
@Override
public void run() {
while (true) {
lock.lock();
try {
if (queueMaxSize == queue.size()) {
log.info("當前隊列已滿,通知消費者消費");
//等待不滿條件觸發(fā)
notFull.await();
}
queue.offer(1);
log.info("生產者補貨,當前隊列有 【{}】", queue.size());
//通知消費者隊列不空,可以消費
notEmpty.signal();
} catch (Exception e) {
log.error("生產者報錯,失敗原因 [{}]", e.getMessage(), e);
} finally {
lock.unlock();
}
}
}
}
/**
* 消費者
*/
private class Consumer extends Thread {
@Override
public void run() {
while (true) {
lock.lock();
try {
if (0 == queue.size()) {
log.info("當前隊列已空,通知生產者補貨");
//等待不空條件達到
notEmpty.await();
}
queue.poll();
//通知消費者不滿了
notFull.signal();
log.info("消費者完成消費,當前隊列還剩余 【{}】個元素", queue.size());
} catch (Exception e) {
log.error("生產者報錯,失敗原因 [{}]", e.getMessage(), e);
} finally {
lock.unlock();
}
}
}
}
public static void main(String[] args) {
ProducerMode mode = new ProducerMode();
Producer producer = mode.new Producer();
ProducerMode.Consumer consumer = mode.new Consumer();
producer.start();
consumer.start();
}
}
對應的我們給出輸出結果:
四、CyclicBarrier
1. CyclicBarrier 原理和使用示例
CyclicBarrier 也就是循環(huán)柵欄對象,不是很常用,它主要用于等待線程數就緒后執(zhí)行公共邏輯的業(yè)務場景。 例如我們希望每湊齊5個線程后執(zhí)行后續(xù)邏輯,我們就可以說明CyclicBarrier 數值為5,然后每個線程到期后調用await等待其他線程就緒。
一旦到齊5個,CyclicBarrier 就會通知這些線程開始工作,對應的代碼如下所示:
public static void main(String[] args) throws InterruptedException {
int threadCount = 5;
CyclicBarrier barrier = new CyclicBarrier(threadCount);
for (int i = 0; i < 5; i++) {
new Thread(() -> {
System.out.println("線程 " + Thread.currentThread().getName() + " 開始執(zhí)行任務");
try {
// 模擬執(zhí)行任務
Thread.sleep(1000);
System.out.println("線程 " + Thread.currentThread().getName() + " 到達屏障");
barrier.await();
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
System.out.println("所有線程都到達屏障,一起繼續(xù)執(zhí)行");
}).start();
}
}
對應的我們給出相應輸出示例:
2. CyclicBarrier 下的多核并發(fā)運算技巧
利用循環(huán)柵欄的特點,我們可以很好基于計算機核心數完成所有的耗時運算,等待所有計算完成之后,通過柵欄來匯聚計算結果打印輸出:
對應我們給出主線程的實現,可以看到該處理器會得到一個與核心數一致的列表,并將列表中的每個子列表交由worker線程處理,每當worker完成列表中一個元素運算后,就會觸發(fā)柵欄的方法打印結果:
public class ArraySquareCalculator {
private final List<List<Integer>> taskList;
private final Worker[] workers;
private final CyclicBarrier barrier;
public ArraySquareCalculator(List<List<Integer>> taskList) {
if (taskList == null || taskList.isEmpty()) {
throw new RuntimeException("任務列表不能為空");
}
if (taskList.size() != Runtime.getRuntime().availableProcessors()) {
throw new RuntimeException("任務列表數量必須等于CPU數量");
}
this.taskList = taskList;
barrier = new CyclicBarrier(taskList.size(), () -> {
Console.log("所有線程都到達屏障,執(zhí)行結束");
Console.log("執(zhí)行結果:{}", JSONUtil.toJsonStr(taskList));
});
workers = new Worker[taskList.size()];
for (int i = 0; i < taskList.size(); i++) {
workers[i] = new Worker(i, taskList, barrier);
}
}
//啟動核心數對應的工作線程執(zhí)行運算
public synchronized void start() {
for (Worker worker : workers) {
new Thread(worker).start();
}
}
}
對應的我們也給出worker子線程代碼,可以看到核心數對應的子線程worker完成各自負責列表的元素運算后,就會通過柵欄提交給主線程告知完成:
public class Worker implements Runnable {
private final int elementIdx;
private final List<List<Integer>> list;
private final CyclicBarrier barrier;
Worker(int elementIdx, List<List<Integer>> list, CyclicBarrier cyclicBarrier) {
this.elementIdx = elementIdx;
this.list = list;
this.barrier = cyclicBarrier;
}
@SneakyThrows
@Override
public void run() {
//每個核心對應的線程處理各自索引列表
List<Integer> workList = list.get(elementIdx);
for (int i = 0; i < workList.size(); i++) {
//完成負責列表元素計算后,通過屏障等待所有線程完成
workList.set(i, workList.get(i) << 1);
barrier.await();
}
}
}
對應的我們也給出測試代碼:
//創(chuàng)建一個與核心數一樣的列表
int size = Runtime.getRuntime().availableProcessors();
List<List<Integer>> list = new ArrayList<>();
//添加元素到列表中
for (int i = 0; i < size; i++) {
ArrayList<Integer> arrayList = new ArrayList<>();
for (int j = 1; j <= 3; j++) {
arrayList.add(j);
}
list.add(arrayList);
}
//啟動并行運算處理器
ArraySquareCalculator calculator = new ArraySquareCalculator(list);
calculator.start();
輸出結果如下,與預期一致:
3. CyclicBarrier如何控制并發(fā)
以上面并行核心線程運算邏輯為例,本質上await方法調用后底層就會完成count扣減,當count為0后就會觸發(fā)一次主線程邏輯調用,也就是我們的打印輸出,即通過count來完成線程之間的循環(huán)并發(fā)流程阻塞和通知:
對應的我們也給出await的源碼,可以看到其內部是通過調用dowait執(zhí)行上述所說邏輯:
public int await() throws InterruptedException, BrokenBarrierException {
try {
return dowait(false, 0L);
} catch (TimeoutException toe) {
throw new Error(toe); // cannot happen
}
}
查看dowait即可印證我們的邏輯:
- 所有線程調用await執(zhí)行count扣減
- count為0調用barrierCommand也就是我們初始化時設置的打印輸出方法
完成barrierCommand任務執(zhí)行后調用nextGeneration將count重置為初始化時的數值,對應的我們的代碼就是CPU核心數
private int dowait(boolean timed, long nanos)
throws InterruptedException, BrokenBarrierException,
TimeoutException {
final ReentrantLock lock = this.lock;
lock.lock();
try {
final Generation g = generation;
//......
int index = --count;
//count扣減為0 步入執(zhí)行邏輯
if (index == 0) { // tripped
boolean ranAction = false;
try {
//調用barrierCommand執(zhí)行歸并邏輯運算
final Runnable command = barrierCommand;
if (command != null)
command.run();
ranAction = true;
//將count重置為初始值
nextGeneration();
return 0;
} finally {
if (!ranAction)
breakBarrier();
}
}
//......
} finally {
lock.unlock();
}
}
五、關于CyclicBarrier 與CountDownLatch的更進一步的理解
1. 二者使用維度上的理解
基于上述的例子,我們不難看出CyclicBarrier 與CountDownLatch在使用維度和工作理念上的區(qū)別,在執(zhí)行層面上,CountDownLatch針對的是事件而非針線程,即通過事件對象countdown結束阻塞,即針對CountDownLatch指定次數n并非需要n個線程把控,例如CountDownLatch設置為3,即使只有兩個線程也依然可以釋放CountDownLatch這個閉鎖從而喚醒阻塞線程:
而CyclicBarrier 循環(huán)柵欄作用于線程,即指定CyclicBarrier的線程數parties,就必須有parties個線程就緒:
同時在使用的角度來說,循環(huán)柵欄CyclicBarrier可重復使用,CountDownLatch則不能
2. 關于CountDownLatch不完美的就緒和解決思路
上文例子中筆者基于CountDownLatch給出了一個運動員賽跑的例子,但這個例子存在一個小小的瑕疵,即利用CountDownLatch避免了運動員提前起跑,但未能保證所有運動員準備就緒。如下圖,在CountDownLatch未執(zhí)行countDown時,線程0和線程1已經到達await阻塞,但是線程2還未到達await,此時就會出現這種情況:
- CountDownLatch執(zhí)行countDown,喚醒阻塞線程
- 線程0和線程1起跑
- 線程0和線程1到達終點
- 線程2才到達await,此時已經countDown,在上述線程完成比賽后才到終點
如下所示,筆者這里利用線程模式調試復現出了該問題:
百米跑比賽開始
[0]號運動員已就緒
[1]號運動員已就緒
[0]號運動員已經到達終點
[1]號運動員已經到達終點
[2]號運動員已就緒
[2]號運動員已經到達終點
所以,我們就必須使用某個讓所有線程就緒之后再使用CountDownLatch模擬槍響后運動員起跑,于是我們就想到以線程為維度控制并發(fā)的CyclicBarrier,即利用CyclicBarrier確保所有運動員就緒后,再執(zhí)行CountDownLatch讓所有線程同時起跑運行:
對應我們給出優(yōu)化后的代碼:
Console.log("百米跑比賽開始");
int playerNum = 3;
CountDownLatch gun = new CountDownLatch(1);
CyclicBarrier barrier = new CyclicBarrier(playerNum, () -> {
Console.log("所有運動員已就緒,開始比賽");
//按下槍 所有運動員起跑
gun.countDown();
});
ExecutorService threadPool = Executors.newFixedThreadPool(playerNum);
for (int i = 0; i < playerNum; i++) {
final int playNo = i;
threadPool.submit(() -> {
try {
Console.log("[{}]號運動員已就緒", playNo);
barrier.await();
gun.await();
} catch (Exception e) {
Console.log("[{}]號運動員線程阻塞失敗,失敗原因[{}]", playNo, e.getMessage(), e);
}
Console.log("[{}]號運動員已經到達終點", playNo);
});
}
threadPool.shutdown();
while (!threadPool.isTerminated()) {
}
Console.log("百米賽跑已結束");
對應輸出結果如下,可以看到此時所有運動員就是同時就緒,同時起跑了:
百米跑比賽開始
[0]號運動員已就緒
[2]號運動員已就緒
[1]號運動員已就緒
所有運動員已就緒,開始比賽
[1]號運動員已經到達終點
[2]號運動員已經到達終點
[0]號運動員已經到達終點
百米賽跑已結束