1. 程式人生 > >軟體事務記憶體導論(十一)-STM的侷限性

軟體事務記憶體導論(十一)-STM的侷限性

宣告:本文是《Java虛擬機器併發程式設計》的第六章,感謝華章出版社授權併發程式設計網站釋出此文,禁止以任何形式轉載此文。

1.1    STM的侷限性

STM消除了顯式的同步操作,所以我們在寫程式碼時就無需擔心自己是否忘了進行同步或是否在錯誤的層級上進行了同步。然而STM本身也存在一些問題,比如在跨越記憶體柵欄失敗或遭遇競爭條件時我們捕獲不到任何有用的資訊。我似乎可以聽到你內心深處那個精明的程式設計師在抱怨“怎麼會這樣啊?”。確實,STM是有其侷限性的,否則本書寫到這裡就應該結束了。STM只適用於寫衝突非常少的應用場景,如果你的應用程式存在很多寫操作競爭,那麼我們就需要在STM之外尋找解決方案了。

下面讓我們進一步討論STM的侷限性。STM提供了一種顯式的鎖無關程式設計模型,這種模型允許多個事務併發地執行,並且在沒有發生衝突時所有事務都能毫無滯礙地執行,所以相對其他程式設計模型而言STM可以提供更好的併發性和執行緒安全方面的保障。當事務對相同物件或資料的寫訪問發生衝突時,只有一個事務能夠順利完成,其他事務都會被自動重做。這種重做機制延緩了寫操作衝突時競爭失敗的那些寫者的執行,但卻提升了讀者和競爭操作的勝利者的執行速度。當對於相同物件的併發寫操作不頻繁時,其效能就不會受到太大影響。但是隨著衝突的增多,程式整體效能將因此變得越來越差。

如果對相同資料有很高的寫衝突概率,那麼我們的應用程式輕則寫操作變慢,重則會因為重試太多次而導致失敗。目前在本章我們所看到的例子都是在展示STM的優勢,但是在下面的例子中我們將會看到,雖然STM是易於使用的,但也並非在所有應用場景下都能得到理想的結果。

在4.2節的示例中,當多個執行緒同時訪問多個目錄時,我們使用AtomicLong來對檔案大小的併發更新操作進行同步。此外,如果需要同時更新多個變數,我們也必須依賴同步才能完成。雖然表面看起來使用STM對這段程式碼進行重構似乎是個不錯的選擇,但大量的寫衝突卻使得STM不適用於這個應用場景。下面就讓我們將上述計算目錄大小的程式改用STM實現,並觀察其執行結果是否如我們所預料的那麼差。

在下面的程式碼中,我們沒有使用AtomicLong,而是採用了Akka託管引用作為FileSizeWSTM的屬性欄位。

public class FileSizeWSTM {
private ExecutorService service;
final private Ref<Long> pendingFileVisits = new Ref<Long>(0L);
final private Ref<Long> totalSize = new Ref<Long>(0L);
final private CountDownLatch latch = new CountDownLatch(1);

為了保證安全性,pendingFileVisits的增減都需要在事務內完成。而在之前使用AtomicLong時,我們只需要簡單呼叫incrementAndGet()函式和decrementAndGet()函式就行了。但是由於託管引用都是通用的(generic),沒有專門針對數字型別的處理方法,所以我們還需要針對pendingFileVisits進行一些額外的加工,即把對於pendingFileVisits的操作封裝到一個單獨的函式裡。

private long updatePendingFileVisits(final int value) {
return new Atomic<Long>() {
public Long atomically() {
pendingFileVisits.swap(pendingFileVisits.get() + value);
return pendingFileVisits.get();
}
}.execute();
}

在完成上述定義之後,訪問目錄和計算檔案大小的函式就相對容易多了,我們只需要把程式中的AtomicLong替換成託管引用就好。

private void findTotalSizeOfFilesInDir(final File file) {
try {
if (!file.isDirectory()) {
new Atomic() {
public Object atomically() {
totalSize.swap(totalSize.get() + file.length());
return null;
}
}.execute();
} else {
final File[] children = file.listFiles();
if (children != null) {
for(final File child : children) {
Limitations of STM • 137
updatePendingFileVisits(1);
service.execute(new Runnable() {
public void run() {
findTotalSizeOfFilesInDir(child); }
});
}
}
}
if(updatePendingFileVisits(-1) == 0) latch.countDown();
} catch(Exception ex) {
System.out.println(ex.getMessage());
System.exit(1);
}
}

最後,我們還需要寫一些建立executor服務池和使程式執行起來的程式碼:

private long getTotalSizeOfFile(final String fileName)
throws InterruptedException {
service = Executors.newFixedThreadPool(100);
updatePendingFileVisits(1);
try {
findTotalSizeOfFilesInDir(new File(fileName));
latch.await(100, TimeUnit.SECONDS);
return totalSize.get();
} finally {
service.shutdown();
}
}
public static void main(final String[] args) throws InterruptedException {
final long start = System.nanoTime();
final long total = new FileSizeWSTM().getTotalSizeOfFile(args[0]);
final long end = System.nanoTime();
System.out.println("Total Size: " + total);
System.out.println("Time taken: " + (end - start)/1.0e9);
}
}

由於我懷疑這段程式碼跑起來之後可能有問題,所以如果在程式中抓到事務失敗所導致的異常,我就會結束掉整個應用程式。

根據事務的定義,如果變數的值在事務提交之前發生了改變,那麼事務將會自動重做。在本例中,多個執行緒會同時競爭修改這兩個可變變數,從而導致程式執行變慢或失敗。我們可以在多個不同的目錄上分別執行上述示例程式碼來進行觀察,下面就列出了該示例程式在我的電腦上計算/etc和/usr這兩個目錄的輸出結果:

Total file size for /etc
Total Size: 2266408
Time taken: 0.537082
Total file size for /usr
Too many retries on transaction 'DefaultTransaction', maxRetries = 1000
Too many retries on transaction 'DefaultTransaction', maxRetries = 1000
Too many retries on transaction 'DefaultTransaction', maxRetries = 1000
...

從輸出結果來看,STM版本對於/etc目錄的計算結果與之前使用AtomicLong的那個版本是完全相同的。但是由於會產生過多的重試操作,所以STM版本的執行時間要比後者慢一個數量級。而遍歷/usr目錄的執行情況則更為糟糕,有相當多的事務超過了預設的最大重試限制。雖然我們的邏輯是一抓到異常就會立即終止整個程式,但由於多個事務是併發執行的,所以在程式真正停止之前我們還是能看到多條錯誤資訊輸出到控制檯。

有個別評論家曾建議說是否用commute代替alter會對解決這個問題有所幫助。請回憶我們在6.4節中曾討論過的在Clojure中用來修改託管引用的那三個函式。由於在事務失敗之後不會進行重試,所以commute可以提供比alter更高的併發度。此外,commute也不會在沒有hold住呼叫方事務的情況下就單獨執行提交操作。然而單純就計算目錄大小這個程式而言,使用commute對效能的提升十分有限。在面對結構複雜的大型目錄時,使用該函式也無法在提供良好效能的前提下獲得一致性的結果。除了將alter換成commute之外,我們還可以嘗試將atom與swap!函式一起使用。雖然atom是不可調整並且同步的操作,但其優點是不需要使用事務。此外,atom僅能在對單個變數(例如計算目錄大小示例中用於記錄目錄大小的變數)的變更時使用,並且變更期間不會遇到任何事務性重試。然而,由於在對atom做變更時會產生對使用者透明的同步操作,所以我們依然會遇到同步操作所導致的延遲問題。

由於大量執行緒會同時嘗試更新totalSize變數,所以計算目錄大小示例在實際執行過程中會產生非常頻繁的寫衝突,這也就意味著STM不適合於解決此問題。事實上,當讀操作十分頻繁且寫衝突被控制在合理範圍內時,STM的效能還是不錯的,同時還能幫程式設計師免除顯式同步的負擔。但是在不考慮一般程式中常見的其他導致延時問題的前提下,如果待解決問題中含有大量寫衝突,那就請不要使用STM,而是考慮採用我們在第8章中將會討論的actor模型來避免同步操作。

1.1    小結

STM是一個針對併發問題的非常強大的程式設計模型,該模型有很多優點:

  • STM可以根據應用程式的行為來充分挖掘出其最大的併發潛力。也就是說,用了STM之後,我們可以無需使用過度保守的、需要預先定義的同步操作,而是讓STM動態地管理競爭衝突。
  • STM是一種鎖無關的程式設計模型,該模型可以提供良好的執行緒安全性和很高的併發效能。
  • STM可以保證實體僅能在事務內被更改。
  • STM沒有顯式鎖意味著我們從此無需擔心加鎖順序及其他相關問題。
  • STM可以幫助我們減輕前期設計的決策負擔,有了它我們就無需關心誰對什麼東西上了鎖,而只需放心地把這些工作交給動態隱式組合鎖(implicit lock composition)。

該模型適用於對相同資料存在併發讀且寫衝突不頻繁的應用場景。

如果應用程式的資料訪問方式符合STM的適用範疇,則STM就為我們提供了一種處理共享可變性的高效解決方案。而如果我們的應用場景裡寫衝突非常多,我們可能就會更傾向於使用將在第8章中討論的基於角色(actor)的模型。但在下一章,還是讓我們先學習一下如何在其他JVM上的語言中使用STM程式設計模型。


方 騰飛

花名清英,併發網(ifeve.com)創始人,暢銷書《Java併發程式設計的藝術》作者,螞蟻金服技術專家。目前工作於支付寶微貸事業部,關注網際網路金融,併發程式設計和敏捷實踐。微信公眾號aliqinying。