JavaScript 系列八:同步與異步

"Code tailor",爲前端開發者提供技術相關資訊以及系列基礎文章,微信關注「小和山的菜鳥們」公衆號,及時獲取最新文章。

前言

在開始學習以前,咱們想要告訴您的是,本文章是對本文章是對JavaScript語言知識中異步操做部分的總結,若是您已掌握下面知識事項,則可跳過此環節直接進入題目練習javascript

  • 單線程
  • 同步概念
  • 異步概念
  • 異步操做的模式
  • 異步操做的流程控制
  • 定時器的建立和清除

若是您對某些部分有些遺忘,👇🏻 已經爲您準備好了!前端

彙總總結

單線程

單線程指的是,JavaScript 只在一個線程上運行。也就是說,JavaScript 同時只能執行一個任務,其餘任務都必須在後面排隊等待。java

JavaScript 之因此採用單線程,而不是多線程,跟歷史有關係。JavaScript 從誕生起就是單線程,緣由是不想讓瀏覽器變得太複雜,由於多線程須要共享資源、且有可能修改彼此的運行結果,對於一種網頁腳本語言來講,這就太複雜了。編程

單線程的好處數組

  • 實現起來比較簡單
  • 執行環境相對單純

單線程的壞處瀏覽器

  • 壞處是隻要有一個任務耗時很長,後面的任務都必須排隊等着,會拖延整個程序的執行

若是排隊是由於計算量大,CPU 忙不過來,倒也算了,可是不少時候 CPU 是閒着的,由於 IO 操做(輸入輸出)很慢(好比 Ajax 操做從網絡讀取數據),不得不等着結果出來,再往下執行。JavaScript 語言的設計者意識到,這時 CPU 徹底能夠無論 IO 操做,掛起處於等待中的任務,先運行排在後面的任務。等到 IO 操做返回告終果,再回過頭,把掛起的任務繼續執行下去。這種機制就是 JavaScript 內部採用的「事件循環」機制Event Loop)。服務器

單線程雖然對 JavaScript 構成了很大的限制,但也所以使它具有了其餘語言不具有的優點。若是用得好,JavaScript 程序是不會出現堵塞的,這就是爲何 Node 能夠用不多的資源,應付大流量訪問的緣由。微信

爲了利用多核 CPU 的計算能力,HTML5 提出 Web Worker 標準,容許 JavaScript 腳本建立多個線程,可是子線程徹底受主線程控制,且不得操做 DOM。因此,這個新標準並無改變 JavaScript 單線程的本質。網絡

同步

同步行爲對應內存中順序執行的處理器指令。每條指令都會嚴格按照它們出現的順序來執行,而每條指令執行後也能當即得到存儲在系統本地(如寄存器或系統內存)的信息。這樣的執行流程容易分析程序在執行到代碼任意位置時的狀態(好比變量的值)。多線程

同步操做的例子能夠是執行一次簡單的數學計算:

let xhs = 3

xhs = xhs + 4

在程序執行的每一步,均可以推斷出程序的狀態。這是由於後面的指令老是在前面的指令完成後纔會執行。等到最後一條指定執行完畢,存儲在 xhs 的值就當即可使用。

首先,操做系統會在棧內存上分配一個存儲浮點數值的空間,而後針對這個值作一次數學計算,再把計算結果寫回以前分配的內存中。全部這些指令都是在單個線程中按順序執行的。在低級指令的層面,有充足的工具能夠肯定系統狀態。

異步

異步行爲相似於系統中斷,即當前進程外部的實體能夠觸發代碼執行。異步操做常常是必要的,由於強制進程等待一個長時間的操做一般是不可行的(同步操做則必需要等)。若是代碼要訪問一些高延遲的資源,好比向遠程服務器發送請求並等待響應,那麼就會出現長時間的等待。

異步操做的例子能夠是在定時回調中執行一次簡單的數學計算:

let xhs = 3

setTimeout(() => (xhs = xhs + 4), 1000)

這段程序最終與同步代碼執行的任務同樣,都是把兩個數加在一塊兒,但這一次執行線程不知道 xhs 值什麼時候會改變,由於這取決於回調什麼時候從消息隊列出列並執行。

異步代碼不容易推斷。雖然這個例子對應的低級代碼最終跟前面的例子沒什麼區別,但第二個指令塊(加操做及賦值操做)是由系統計時器觸發的,這會生成一個入隊執行的中斷。到底何時會觸發這個中斷,這對 JavaScript 運行時來講是一個黑盒,所以實際上沒法預知(儘管能夠保證這發生在當前線程的同步代碼執行以後,不然回調都沒有機會出列被執行)。不管如何,在排定回調之後基本沒辦法知道系統狀態什麼時候變化。

爲了讓後續代碼可以使用 xhs ,異步執行的函數須要在更新 xhs 的值之後通知其餘代碼。若是程序不須要這個值,那麼就只管繼續執行,沒必要等待這個結果了。

任務隊列和事件循環

JavaScript 運行時,除了一個正在運行的主線程,引擎還提供一個任務隊列(task queue),裏面是各類須要當前程序處理的異步任務。(實際上,根據異步任務的類型,存在多個任務隊列。爲了方便理解,這裏假設只存在一個隊列。)

首先,主線程會去執行全部的同步任務。等到同步任務所有執行完,就會去看任務隊列裏面的異步任務。若是知足條件,那麼異步任務就從新進入主線程開始執行,這時它就變成同步任務了。等到執行完,下一個異步任務再進入主線程開始執行。一旦任務隊列清空,程序就結束執行。

異步任務的寫法一般是回調函數。一旦異步任務從新進入主線程,就會執行對應的回調函數。若是一個異步任務沒有回調函數,就不會進入任務隊列,也就是說,不會從新進入主線程,由於沒有用回調函數指定下一步的操做。

JavaScript 引擎怎麼知道異步任務有沒有結果,能不能進入主線程呢?答案就是引擎在不停地檢查,一遍又一遍,只要同步任務執行完了,引擎就會去檢查那些掛起來的異步任務,是否是能夠進入主線程了。這種循環檢查的機制,就叫作事件循環(Event Loop)。

維基百科的定義是:「事件循環是一個程序結構,用於等待和發送消息和事件(a programming construct that waits for and dispatches events or messages in a program)」。

異步操做的模式

回調函數

回調函數是異步操做最基本的方法。

下面是兩個函數 f1f2 ,編程的意圖是 f2 必須等到 f1 執行完成,才能執行。

function f1() {
  // ...
}

function f2() {
  // ...
}

f1()
f2()

上面代碼的問題在於,若是 f1 是異步操做,f2 會當即執行,不會等到 f1 結束再執行。

這時,能夠考慮改寫 f1 ,把 f2 寫成 f1 的回調函數。

function f1(callback) {
  // ...
  callback()
}

function f2() {
  // ...
}

f1(f2)

回調函數的優勢是簡單、容易理解和實現,缺點是不利於代碼的閱讀和維護,各個部分之間高度耦合coupling),使得程序結構混亂、流程難以追蹤(尤爲是多個回調函數嵌套的狀況),並且每一個任務只能指定一個回調函數。

異步操做的流程控制

若是有多個異步操做,就存在一個流程控制的問題:如何肯定異步操做執行的順序,以及如何保證遵照這種順序。

function async(arg, callback) {
  console.log('參數爲 ' + arg + ' , 1秒後返回結果')
  setTimeout(function () {
    callback(arg * 2)
  }, 1000)
}

上面代碼的 async 函數是一個異步任務,很是耗時,每次執行須要 1 秒才能完成,而後再調用回調函數。

若是有六個這樣的異步任務,須要所有完成後,才能執行最後的 final 函數。請問應該如何安排操做流程?

function final(value) {
  console.log('完成: ', value)
}

async(1, function (value) {
  async(2, function (value) {
    async(3, function (value) {
      async(4, function (value) {
        async(5, function (value) {
          async(6, final)
        })
      })
    })
  })
})
// 參數爲 1 , 1秒後返回結果
// 參數爲 2 , 1秒後返回結果
// 參數爲 3 , 1秒後返回結果
// 參數爲 4 , 1秒後返回結果
// 參數爲 5 , 1秒後返回結果
// 參數爲 6 , 1秒後返回結果
// 完成:  12

上面代碼中,六個回調函數的嵌套,不只寫起來麻煩,容易出錯,並且難以維護。

串行執行

咱們能夠編寫一個流程控制函數,讓它來控制異步任務,一個任務完成之後,再執行另外一個。這就叫串行執行。

var items = [1, 2, 3, 4, 5, 6]
var results = []

function async(arg, callback) {
  console.log('參數爲 ' + arg + ' , 1秒後返回結果')
  setTimeout(function () {
    callback(arg * 2)
  }, 1000)
}

function final(value) {
  console.log('完成: ', value)
}

function series(item) {
  if (item) {
    async(item, function (result) {
      results.push(result)
      return series(items.shift())
    })
  } else {
    return final(results[results.length - 1])
  }
}

series(items.shift())

上面代碼中,函數 series 就是串行函數,它會依次執行異步任務,全部任務都完成後,纔會執行 final 函數。items 數組保存每個異步任務的參數,results 數組保存每個異步任務的運行結果。

注意,上面的寫法須要六秒,才能完成整個腳本。

並行執行

流程控制函數也能夠是並行執行,即全部異步任務同時執行,等到所有完成之後,才執行final函數。

var items = [1, 2, 3, 4, 5, 6]
var results = []

function async(arg, callback) {
  console.log('參數爲 ' + arg + ' , 1秒後返回結果')
  setTimeout(function () {
    callback(arg * 2)
  }, 1000)
}

function final(value) {
  console.log('完成: ', value)
}

items.forEach(function (item) {
  async(item, function (result) {
    results.push(result)
    if (results.length === items.length) {
      final(results[results.length - 1])
    }
  })
})

上面代碼中,forEach 方法會同時發起六個異步任務,等到它們所有完成之後,纔會執行 final 函數。

相比而言,上面的寫法只要一秒,就能完成整個腳本。這就是說,並行執行的效率較高,比起串行執行一次只能執行一個任務,較爲節約時間。可是問題在於若是並行的任務較多,很容易耗盡系統資源,拖慢運行速度。所以有了第三種流程控制方式。

並行與串行的結合

所謂並行與串行的結合,就是設置一個門檻,每次最多隻能並行執行 n 個異步任務,這樣就避免了過度佔用系統資源。

var items = [1, 2, 3, 4, 5, 6]
var results = []
var running = 0
var limit = 2

function async(arg, callback) {
  console.log('參數爲 ' + arg + ' , 1秒後返回結果')
  setTimeout(function () {
    callback(arg * 2)
  }, 1000)
}

function final(value) {
  console.log('完成: ', value)
}

function launcher() {
  while (running < limit && items.length > 0) {
    var item = items.shift()
    async(item, function (result) {
      results.push(result)
      running--
      if (items.length > 0) {
        launcher()
      } else if (running == 0) {
        final(results)
      }
    })
    running++
  }
}

launcher()

上面代碼中,最多隻能同時運行兩個異步任務。變量 running 記錄當前正在運行的任務數,只要低於門檻值,就再啓動一個新的任務,若是等於0,就表示全部任務都執行完了,這時就執行 final 函數。

這段代碼須要三秒完成整個腳本,處在串行執行和並行執行之間。經過調節 limit 變量,達到效率和資源的最佳平衡。

定時器的建立和清除

JavaScript 在瀏覽器中是單線程執行的,但容許使用定時器指定在某個時間以後或每隔一段時間就執行相應的代碼。setTimeout() 用於指定在必定時間後執行某些代碼,而 setInterval() 用於指定每隔一段時間執行某些代碼。

setTimeout() 方法一般接收兩個參數:要執行的代碼和在執行回調函數前等待的時間(毫秒)。第一個參數能夠是包含 JavaScript 代碼的字符串(相似於傳給 eval() 的字符串)或者一個函數。

// 在 1 秒後顯示警告框
setTimeout(() => alert('Hello XHS-Rookies!'), 1000)

第二個參數是要等待的毫秒數,而不是要執行代碼的確切時間。 JavaScript 是單線程的,因此每次只能執行一段代碼。爲了調度不一樣代碼的執行, JavaScript 維護了一個任務隊列。其中的任務會按照添加到隊列的前後順序執行。 setTimeout() 的第二個參數只是告訴 JavaScript 引擎在指定的毫秒數事後把任務添加到這個隊列。若是隊列是空的,則會當即執行該代碼。若是隊列不是空的,則代碼必須等待前面的任務執行完才能執行。

調用 setTimeout() 時,會返回一個表示該超時排期的數值 ID。這個超時 ID 是被排期執行代碼的惟一標識符,可用於取消該任務。要取消等待中的排期任務,能夠調用 clearTimeout() 方法並傳入超時 ID,以下面的例子所示:

// 設置超時任務
let timeoutId = setTimeout(() => alert('Hello XHS-Rookies!'), 1000)// 取消超時任務clearTimeout(timeoutId)

只要是在指定時間到達以前調用 clearTimeout() ,就能夠取消超時任務。在任務執行後再調用 clearTimeout() 沒有效果。

注意 全部超時執行的代碼(函數)都會在全局做用域中的一個匿名函數中運行,所以函數中的 this 值在非嚴格模式下始終指向 window,而在嚴格模式下是 undefined。若是給 setTimeout() 提供了一個箭頭函數,那麼 this 會保留爲定義它時所在的詞彙做用域。

setInterval()setTimeout() 的使用方法相似,只不過指定的任務會每隔指定時間就執行一次,直到取消循環定時或者頁面卸載。setInterval() 一樣能夠接收兩個參數:要執行的代碼(字符串或函數),以及把下一次執行定時代碼的任務添加到隊列要等待的時間(毫秒)。下面是一個例子:

setInterval(() => alert('Hello XHS-Rookies!'), 10000)

注意 這裏的關鍵點是,第二個參數,也就是間隔時間,指的是向隊列添加新任務以前等待的時間。好比,調用 setInterval() 的時間爲 01:00:00,間隔時間爲 3000 毫秒。這意味着 01:00:03 時,瀏覽器會把任務添加到執行隊列。瀏覽器不關心這個任務何時執行或者執行要花多長時間。所以,到了 01:00:06,它會再向隊列中添加一個任務。由此可看出,執行時間短、非阻塞的回調函數比較適合 setInterval()

setInterval() 方法也會返回一個循環定時 ID,能夠用於在將來某個時間點上取消循環定時。要取消循環定時,能夠調用 clearInterval() 並傳入定時 ID。相對於 setTimeout() 而言,取消定時的能力對 setInterval() 更加劇要。畢竟,若是一直無論它,那麼定時任務會一直執行到頁面卸載。下面是一個常見的例子:

let xhsNum = 0,
  intervalId = null
let xhsMax = 10

let xhsIncrementNumber = function () {
  xhsNum++
  // 若是達到最大值,則取消全部未執行的任務
  if (xhsNum == xhsMax) {
    clearInterval(xhsIntervalId) // 清除定時器
    alert('Done')
  }
}
xhsIntervalId = setInterval(xhsIncrementNumber, 500)

在這個例子中,變量 num 會每半秒遞增一次,直至達到最大限制值。此時循環定時會被取消。這個模式也可使用 setTimeout() 來實現,好比:

let xhsNum = 0
let xhsMax = 10

let xhsIncrementNumber = function () {
  xhsNum++
  // 若是尚未達到最大值,再設置一個超時任務
  if (xhsNum < xhsMax) {
    setTimeout(xhsIncrementNumber, 500)
  } else {
    alert('Done')
  }
}
setTimeout(xhsIncrementNumber, 500)
注意在使用 setTimeout() 時,不必定要記錄超時 ID,由於它會在條件知足時自動中止,不然會自動設置另外一個超時任務。這個模式是設置循環任務的推薦作法。 setIntervale() 在實踐中不多會在生產環境下使用,由於一個任務結束和下一個任務開始之間的時間間隔是沒法保證的,有些循環定時任務可能會所以而被跳過。而像前面這個例子中同樣使用 setTimeout() 則能確保不會出現這種狀況。通常來講,最好不要使用 setInterval()

題目自測

1、如下代碼輸出是什麼?

console.log('first')
setTimeOut(() => {
  console.log('second')
}, 1000)
console.log('third')

2、製做一個 60s 計時器。

題目解析

1、

// first
// third
// second

setTimeOut 執行時使裏面的內容進入異步隊列。因此會先執行下面的 third 輸出以後,才輸出 setTimeOut 中的內容。

2、

function XhsTimer() {
  var xhsTime = 60 // 設置倒計時時間 60s
  const xhsTimer = setInterval(() => {
    // 建立定時器
    if (xhsTime > 0) {
      // 大於 0 時,一次次減
      xhsTime--
      console.log(xhsTime) // 輸出每一秒
    } else {
      clearInterval(xhsTimer) // 清除定時器
      xhsTime = 60 // 從新設置倒計時時間 60s
    }
  }, 1000) // 1000 爲設置的時間,1000毫秒 也就是一秒
}
XhsTimer()
相關文章
相關標籤/搜索