<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
在io比較多的場景中, Async
語法編寫的程式會以更少的時間, 更少的資源來完成相同的任務, 這篇文章則是介紹了Python
的Async
語法的協程是如何實現的。
還是一樣, 在瞭解Async
語法的實現之前, 先從一個Sync
的語法例子開始, 現在假設有一個HTTP請求, 這個程式會通過這個請求獲取對應的響應內容, 並列印出來, 程式碼如下:
import socket def request(host: str) -> None: """模擬請求並列印響應體""" url: str = f"http://{host}" sock: socket.SocketType = socket.socket() sock.connect((host, 80)) sock.send(f"GET {url} HTTP/1.0rnHost: {host}rnrn".encode("ascii")) response_bytes: bytes = b"" chunk: bytes = sock.recv(4096) while chunk: response_bytes += chunk chunk = sock.recv(4096) print("n".join([i for i in response_bytes.decode().split("rn")])) if __name__ == "__main__": request("so1n.me")
執行程式, 程式能夠正常輸出, 上部分列印了對應的HTTP響應Header, 下部分列印了HTTP響應體, , 可以看到伺服器端叫我們以https的形式重新請求, 輸出結果如下:
HTTP/1.1 301 Moved Permanently Server: GitHub.com Content-Type: text/html Location: https://so1n.me/ X-GitHub-Request-Id: A744:3871:4136AF:48BD9F:6188DB50 Content-Length: 162 Accept-Ranges: bytes Date: Mon, 08 Nov 2021 08:11:37 GMT Via: 1.1 varnish Age: 104 Connection: close X-Served-By: cache-qpg1272-QPG X-Cache: HIT X-Cache-Hits: 2 X-Timer: S1636359097.026094,VS0,VE0 Vary: Accept-Encoding X-Fastly-Request-ID: 22fa337f777553d33503cee5282598c6a293fb5e <html> <head><title>301 Moved Permanently</title></head> <body> <center><h1>301 Moved Permanently</h1></center> <hr><center>nginx</center> </body> </html>
不過這裡並不是想說HTTP請求是如何實現的, 具體我也不太瞭解, 在這個程式碼中, socket的預設呼叫是阻塞的, 當執行緒呼叫connect
或者recv
時(send
是不用等待的, 但在高並行下需要先等待drain
後才可以send
, 小demo不需要用到drain
方法), 程式將會暫停直到操作完成。 當一次要下載很多網頁的話, 這將會如上篇文章所說的一樣, 大部分的等待時間都花在io上面, cpu卻一直空閒時, 而使用執行緒池雖然可以解決這個問題, 但是開銷是很大的, 同時作業系統往往會限制一個程序,使用者或者機器可以使用的執行緒數, 而協程卻沒有這些限制, 佔用的資源少, 也沒有系統限制瓶頸。
非同步可以讓一個單獨的執行緒處理並行的操作, 不過在上面已經說過了, socket是預設阻塞的, 所以需要把socket設定為非阻塞的, socket提供了setblocking
這個方法供開發者選擇是否阻塞, 在設定了非阻塞後, connect
和recv
方法也要進行更改。
由於沒有了阻塞, 程式在呼叫了connect
後會馬上返回, 只不過Python
的底層是C
, 這段程式碼在C
中呼叫非阻塞的socket.connect後會丟擲一個異常, 我們需要捕獲它, 就像這樣:
import socket sock: socket.SocketType = socket.socket() sock.setblocking(Flase) try: sock.connect(("so1n.me", 80)) except BlockingIOError: pass
經過一頓操作後, 就開始申請建立連線了, 但是我們還不知道連線啥時候完成建立, 由於連線沒建立時呼叫send
會報錯, 所以可以一直輪詢呼叫send
直到沒報錯就認為是成功(真實程式碼需要加超時):
while True: try: sock.send(request) break except OSError as e: pass
但是這樣讓CPU空轉太浪費效能了, 而且期間還不能做別的事情, 就像我們點外賣後一直打電話過去問飯菜做好了沒有, 十分浪費電話費用, 要是飯菜做完了就打電話告訴我們, 那就只產生了一筆費用, 非常的省錢(正常情況下也是這樣子)。
這時就需要事件迴圈登場了,在類UNIX中, 有一個叫select
的功能, 它可以等待事件發生後再呼叫監聽的函數, 不過一開始的實現效能不是很好, 在Linux
上被epoll
取代, 不過介面是類似的, 所在在Python
中把這幾個不同的事件迴圈都封裝在selectors
庫中, 同時可以通過DefaultSelector
從系統中挑出最好的類select
函數。
這裡先暫時不說事件迴圈的原理, 事件迴圈最主要的是他名字的兩部分, 一個是事件, 一個是迴圈, 在Python
中, 可以通過如下方法把事件註冊到事件迴圈中:
def demo(): pass selector.register(fd, EVENT_WRITE, demo)
這樣這個事件迴圈就會監聽對應的檔案描述符fd, 當這個檔案描述符觸發寫入事件(EVENT_WRITE)時,事件迴圈就會告訴我們可以去呼叫註冊的函數demo
。不過如果把上面的程式碼都改為這種方法去執行的話就會發現, 程式好像沒跑就結束了, 但程式其實是有跑的, 只不過他們是完成的了註冊, 然後就等待開發者接收事件迴圈的事件進行下一步的操作, 所以我們只需要在程式碼的最後面寫上如下程式碼:
while True: for key, mask in selector.select(): key.data()
這樣程式就會一直執行, 當捕獲到事件的時候, 就會通過for迴圈告訴我們, 其中key.data
是我們註冊的回撥函數, 當事件發生時, 就會通知我們, 我們可以通過拿到回撥函數然後就執行, 瞭解完畢後, 我們可以來編寫我們的第一個並行程式, 他實現了一個簡單的I/O複用的小邏輯, 程式碼和註釋如下:
import socket from selectors import DefaultSelector, EVENT_READ, EVENT_WRITE # 選擇事件迴圈 selector: DefaultSelector = DefaultSelector() # 用於判斷是否有事件在執行 running_cnt: int = 0 def request(host: str) -> None: """模擬請求並列印響應體""" # 告訴主函數, 自己的事件還在執行 global running_cnt running_cnt += 1 # 初始化socket url: str = f"http://{host}" sock: socket.SocketType = socket.socket() sock.setblocking(False) try: sock.connect((host, 80)) except BlockingIOError: pass response_bytes: bytes = b"" def read_response() -> None: """接收響應引數, 並判斷請求是否結束""" nonlocal response_bytes chunk: bytes = sock.recv(4096) print(f"recv {host} body success") if chunk: response_bytes += chunk else: # 沒有資料代表請求結束了, 登出監聽 selector.unregister(sock.fileno()) global running_cnt running_cnt -= 1 def connected() -> None: """socket建立連線時的回撥""" # 取消監聽 selector.unregister(sock.fileno()) print(f"{host} connect success") # 傳送請求, 並監聽讀事件, 以及註冊對應的接收響應函數 sock.send(f"GET {url} HTTP/1.0rnHost: {host}rnrn".encode("ascii")) selector.register(sock.fileno(), EVENT_READ, read_response) selector.register(sock.fileno(), EVENT_WRITE, connected) if __name__ == "__main__": # 同時多個請求 request("so1n.me") request("github.com") request("google.com") request("baidu.com") # 監聽是否有事件在執行 while running_cnt > 0: # 等待事件迴圈通知事件是否已經完成 for key, mask in selector.select(): key.data()
這段程式碼接近同時註冊了4個請求並註冊建立連線回撥, 然後就進入事件迴圈邏輯, 也就是把控制權交給事件迴圈, 直到事件迴圈告訴程式說收到了socket建立的通知, 程式就會取消註冊的回撥然後傳送請求, 並註冊一個讀的事件回撥, 然後又把控制權交給事件迴圈, 直到收到了響應的結果才進入處理響應結果函數並且只有收完所有響應結果才會退出程式。
下面是我其中的一次執行結果
so1n.me connect success
github.com connect success
google.com connect success
recv google.com body success
recv google.com body success
baidu.com connect success
recv github.com body success
recv github.com body success
recv baidu.com body success
recv baidu.com body success
recv so1n.me body success
recv so1n.me body success
可以看到他們的執行順序是隨機的, 不是嚴格的按照so1n.me
, github.com
, google.com
, baidu.com
順序執行, 同時他們執行速度很快, 這個程式的耗時約等於響應時長最長的函數耗時。
但是可以看出, 這個程式裡面出現了兩個回撥, 回撥會讓程式碼變得非常的奇怪, 降低可讀性, 也容易造成回撥地獄, 而且當回撥發生報錯的時候, 我們是很難知道這是由於什麼導致的錯誤, 因為它的上下文丟失了, 這樣子排查問題十分的困惑。 作為程式設計師, 一般都不止滿足於速度快的程式碼, 真正想要的是又快, 又能像Sync
的程式碼一樣簡單, 可讀性強, 也能容易排查問題的程式碼, 這種組合形式的程式碼的設計模式就叫協程。
協程出現得很早, 它不像執行緒一樣, 被系統排程, 而是能自主的暫停, 並等待事件迴圈通知恢復。由於協程是軟體層面實現的, 所以它的實現方式有很多種, 這裡要說的是基於生成器的協程, 因為生成器跟協程一樣, 都有暫停讓步和恢復的方法(還可以通過throw
來拋錯), 同時它跟Async
語法的協程很像, 通過了解基於生成器的協程, 可以瞭解Async
的協程是如何實現的。
在瞭解基於生成器的協程之前, 需要先了解下生成器, Python
的生成器函數與普通的函數會有一些不同, 只有普通函數中帶有關鍵字yield
, 那麼它就是生成器函數, 具體有什麼不同可以通過他們的位元組碼來了解:
In [1]: import dis # 普通函數 In [2]: def aaa(): pass In [3]: dis.dis(aaa) 1 0 LOAD_CONST 0 (None) 2 RETURN_VALUE # 普通函數呼叫函數 In [4]: def bbb(): ...: aaa() ...: In [5]: dis.dis(bbb) 2 0 LOAD_GLOBAL 0 (aaa) 2 CALL_FUNCTION 0 4 POP_TOP 6 LOAD_CONST 0 (None) 8 RETURN_VALUE # 普通生成器函數 In [6]: def ccc(): yield In [7]: dis.dis(ccc) 1 0 LOAD_CONST 0 (None) 2 YIELD_VALUE 4 POP_TOP 6 LOAD_CONST 0 (None) 8 RETURN_VALUE
上面分別是普通函數, 普通函數呼叫函數和普通生成器函數的位元組碼, 從位元組碼可以看出來, 最簡單的函數只需要LOAD_CONST
來載入變數None壓入自己的棧, 然後通過RETURN_VALUE
來返回值, 而有函數呼叫的普通函數則先載入變數, 把全域性變數的函數aaa
載入到自己的棧裡面, 然後通過CALL_FUNCTION
來呼叫函數, 最後通過POP_TOP
把函數的返回值從棧裡丟擲來, 再把通過LOAD_CONST
把None壓入自己的棧, 最後返回值。
而生成器函數則不一樣, 它會先通過LOAD_CONST
來載入變數None壓入自己的棧, 然後通過YIELD_VALUE
返回值, 接著通過POP_TOP
彈出剛才的棧並重新把變數None壓入自己的棧, 最後通過RETURN_VALUE
來返回值。從位元組碼來分析可以很清楚的看到, 生成器能夠在yield
區分兩個棧幀, 一個函數呼叫可以分為多次返回, 很符合協程多次等待的特點。
接著來看看生成器的一個使用, 這個生成器會有兩次yield
呼叫, 並在最後返回字串'None'
, 程式碼如下:
In [8]: def demo(): ...: a = 1 ...: b = 2 ...: print('aaa', locals()) ...: yield 1 ...: print('bbb', locals()) ...: yield 2 ...: return 'None' ...: In [9]: demo_gen = demo() In [10]: demo_gen.send(None) aaa {'a': 1, 'b': 2} Out[10]: 1 In [11]: demo_gen.send(None) bbb {'a': 1, 'b': 2} Out[11]: 2 In [12]: demo_gen.send(None) --------------------------------------------------------------------------- StopIteration Traceback (most recent call last) <ipython-input-12-8f8cb075d6af> in <module> ----> 1 demo_gen.send(None) StopIteration: None
這段程式碼首先通過函數呼叫生成一個demo_gen
的生成器物件, 然後第一次send
呼叫時返回值1, 第二次send
呼叫時返回值2, 第三次send
呼叫則丟擲StopIteration
異常, 異常提示為None
, 同時可以看到第一次列印aaa
和第二次列印bbb
時, 他們都能列印到當前的函數區域性變數, 可以發現在即使在不同的棧幀中, 他們讀取到當前的區域性函數內的區域性變數是一致的, 這意味著如果使用生成器來模擬協程時, 它還是會一直讀取到當前上下文的, 非常的完美。
此外, Python
還支援通過yield from
語法來返回一個生成器, 程式碼如下:
In [1]: def demo_gen_1(): ...: for i in range(3): ...: yield i ...: In [2]: def demo_gen_2(): ...: yield from demo_gen_1() ...: In [3]: demo_gen_obj = demo_gen_2() In [4]: demo_gen_obj.send(None) Out[4]: 0 In [5]: demo_gen_obj.send(None) Out[5]: 1 In [6]: demo_gen_obj.send(None) Out[6]: 2 In [7]: demo_gen_obj.send(None) --------------------------------------------------------------------------- StopIteration Traceback (most recent call last) <ipython-input-7-f9922a2f64c9> in <module> ----> 1 demo_gen_obj.send(None) StopIteration:
通過yield from
就可以很方便的支援生成器呼叫, 假如把每個生成器函數都當做一個協程, 那通過yield from
就可以很方便的實現協程間的呼叫, 此外生成器的丟擲異常後的提醒非常人性化, 也支援throw
來丟擲異常, 這樣我們就可以實現在協程執行時設定異常, 比如Cancel
,演示程式碼如下:
In [1]: def demo_exc(): ...: yield 1 ...: raise RuntimeError() ...: In [2]: def demo_exc_1(): ...: for i in range(3): ...: yield i ...: In [3]: demo_exc_gen = demo_exc() In [4]: demo_exc_gen.send(None) Out[4]: 1 In [5]: demo_exc_gen.send(None) --------------------------------------------------------------------------- RuntimeError Traceback (most recent call last) <ipython-input-5-09fbb75fdf7d> in <module> ----> 1 demo_exc_gen.send(None) <ipython-input-1-69afbc1f9c19> in demo_exc() 1 def demo_exc(): 2 yield 1 ----> 3 raise RuntimeError() 4 RuntimeError: In [6]: demo_exc_gen_1 = demo_exc_1() In [7]: demo_exc_gen_1.send(None) Out[7]: 0 In [8]: demo_exc_gen_1.send(None) Out[8]: 1 In [9]: demo_exc_gen_1.throw(RuntimeError) --------------------------------------------------------------------------- RuntimeError Traceback (most recent call last) <ipython-input-9-1a1cc55d71f4> in <module> ----> 1 demo_exc_gen_1.throw(RuntimeError) <ipython-input-2-2617b2366dce> in demo_exc_1() 1 def demo_exc_1(): 2 for i in range(3): ----> 3 yield i 4 RuntimeError:
從中可以看到在執行中丟擲異常時, 會有一個非常清楚的拋錯, 可以明顯看出錯誤堆疊, 同時throw
指定異常後, 會在下一處yield
丟擲異常(所以協程呼叫Cancel
後不會馬上取消, 而是下一次呼叫的時候才被取消)。
我們已經簡單的瞭解到了生成器是非常的貼合協程的程式設計模型, 同時也知道哪些生成器API是我們需要的API, 接下來可以模仿Asyncio
的介面來實現一個簡單的協程。
首先是在Asyncio
中有一個封裝叫Feature
, 它用來表示協程正在等待將來時的結果, 以下是我根據asyncio.Feature
封裝的一個簡單的Feature
, 它的API沒有asyncio.Feature
全, 程式碼和註釋如下:
class Status: """用於判斷Future狀態""" pending: int = 1 finished: int = 2 cancelled: int = 3 class Future(object): def __init__(self) -> None: """初始化時, Feature處理pending狀態, 等待set result""" self.status: int = Status.pending self._result: Any = None self._exception: Optional[Exception] = None self._callbacks: List[Callable[['Future'], None]] = [] def add_done_callback(self, fn: [['Future'], None]Callable) -> None: """新增完成時的回撥""" self._callbacks.append(fn) def cancel(self): """取消當前的Feature""" if self.status != Status.pending: return False self.status = Status.cancelled for fn in self._callbacks: fn(self) return True def set_exception(self, exc: Exception) -> None: """設定異常""" if self.status != Status.pending: raise RuntimeError("Can not set exc") self._exception = exc self.status = Status.finished def set_result(self, result: Any) -> None: """設定結果""" if self.status != Status.pending: raise RuntimeError("Can not set result") self.status = Status.finished self._result = result for fn in self._callbacks: fn(self) def result(self): """獲取結果""" if self.status == Status.cancelled: raise asyncio.CancelledError elif self.status != Status.finished: raise RuntimeError("Result is not read") elif self._exception is not None: raise self._exception return self._result def __iter__(self): """通過生成器來模擬協程, 當收到結果通知時, 會返回結果""" if self.status == Status.pending: yield self return self.result()
在理解Future
時, 可以把它假想為一個狀態機, 在啟動初始化的時候是peding
狀態, 在執行的時候我們可以切換它的狀態, 並且通過__iter__
方法來支援呼叫者使用yield from Future()
來等待Future
本身, 直到收到了事件通知時, 可以得到結果。
但是可以發現這個Future
是無法自我驅動, 呼叫了__iter__
的程式不知道何時被呼叫了set_result
, 在Asyncio
中是通過一個叫Task
的類來驅動Future
, 它將一個協程的執行過程安排好, 並負責在事件迴圈中執行該協程。它主要有兩個方法:
send
方法啟用生成器StopIteration
異常還有一個支援取消執行託管協程的方法(在原始碼中, Task
是繼承於Future
, 所以Future
有的它都有), 經過簡化後的程式碼如下:
class Task: def __init__(self, coro: Generator) -> None: # 初始化狀態 self.cancelled: bool = False self.coro: Generator = coro # 預激一個普通的future f: Future = Future() f.set_result(None) self.step(f) def cancel(self) -> None: """用於取消託管的coro""" self.coro.throw(asyncio.CancelledError) def step(self, f: Future) -> None: """用於呼叫coro的下一步, 從第一次啟用開始, 每次都新增完成時的回撥, 直到遇到取消或者StopIteration異常""" try: _future = self.coro.send(f.result()) except asyncio.CancelledError: self.cancelled = True return except StopIteration: return _future.add_done_callback(self.step)
這樣Future
和Task
就封裝好了, 可以簡單的試一試效果如何:
In [2]:def wait_future(f: Future, flag_int: int) -> Generator[Future, None, None]: ...: result = yield from f ...: print(flag_int, result) ...: ...:future: Future = Future() ...:for i in range(3): ...: coro = wait_future(future, i) ...: # 託管wait_future這個協程, 裡面的Future也會通過yield from被託管 ...: Task(coro) ...: ...:print('ready') ...:future.set_result('ok') ...: ...:future = Future() ...:Task(wait_future(future, 3)).cancel() ...: ready 0 ok 1 ok 2 ok --------------------------------------------------------------------------- CancelledError Traceback (most recent call last) <ipython-input-2-2d1b04db2604> in <module> 12 13 future = Future() ---> 14 Task(wait_future(future, 3)).cancel() <ipython-input-1-ec3831082a88> in cancel(self) 81 82 def cancel(self) -> None: ---> 83 self.coro.throw(asyncio.CancelledError) 84 85 def step(self, f: Future) -> None: <ipython-input-2-2d1b04db2604> in wait_future(f, flag_int) 1 def wait_future(f: Future, flag_int: int) -> Generator[Future, None, None]: ----> 2 result = yield from f 3 print(flag_int, result) 4 5 future: Future = Future() <ipython-input-1-ec3831082a88> in __iter__(self) 68 """通過生成器來模擬協程, 當收到結果通知時, 會返回結果""" 69 if self.status == Status.pending: ---> 70 yield self 71 return self.result() 72 CancelledError:
這段程式會先初始化Future
, 並把Future
傳給wait_future
並生成生成器, 再交由給Task
託管, 預激, 由於Future
是在生成器函數wait_future
中通過yield from
與函數繫結的, 真正被預激的其實是Future
的__iter__
方法中的yield self
, 此時程式碼邏輯會暫停在yield self
並返回。
在全部預激後, 通過呼叫Future
的set_result
方法, 使Future
變為結束狀態, 由於set_result
會執行註冊的回撥, 這時它就會執行託管它的Task
的step
方法中的send
方法, 程式碼邏輯回到Future
的__iter__
方法中的yield self
, 並繼續往下走, 然後遇到return
返回結果, 並繼續走下去, 從輸出可以發現程式封裝完成且列印了ready
後, 會依次列印對應的返回結果, 而在最後一個的測試cancel
方法中可以看到,Future
丟擲異常了, 同時這些異常很容易看懂, 能夠追隨到呼叫的地方。
現在Future
和Task
正常執行了, 可以跟我們一開始執行的程式進行整合, 程式碼如下:
class HttpRequest(object): def __init__(self, host: str): """初始化變數和sock""" self._host: str = host global running_cnt running_cnt += 1 self.url: str = f"http://{host}" self.sock: socket.SocketType = socket.socket() self.sock.setblocking(False) try: self.sock.connect((host, 80)) except BlockingIOError: pass def read(self) -> Generator[Future, None, bytes]: """從socket獲取響應資料, 並set到Future中, 並通過Future.__iter__方法或得到資料並通過變數chunk_future返回""" f: Future = Future() selector.register(self.sock.fileno(), EVENT_READ, lambda: f.set_result(self.sock.recv(4096))) chunk_future = yield from f selector.unregister(self.sock.fileno()) return chunk_future # type: ignore def read_response(self) -> Generator[Future, None, bytes]: """接收響應引數, 並判斷請求是否結束""" response_bytes: bytes = b"" chunk = yield from self.read() while chunk: response_bytes += chunk chunk = yield from self.read() return response_bytes def connected(self) -> Generator[Future, None, None]: """socket建立連線時的回撥""" # 取消監聽 f: Future = Future() selector.register(self.sock.fileno(), EVENT_WRITE, lambda: f.set_result(None)) yield f selector.unregister(self.sock.fileno()) print(f"{self._host} connect success") def request(self) -> Generator[Future, None, None]: # 傳送請求, 並監聽讀事件, 以及註冊對應的接收響應函數 yield from self.connected() self.sock.send(f"GET {self.url} HTTP/1.0rnHost: {self._host}rnrn".encode("ascii")) response = yield from self.read_response() print(f"request {self._host} success, length:{len(response)}") global running_cnt running_cnt -= 1 if __name__ == "__main__": # 同時多個請求 Task(HttpRequest("so1n.me").request()) Task(HttpRequest("github.com").request()) Task(HttpRequest("google.com").request()) Task(HttpRequest("baidu.com").request()) # 監聽是否有事件在執行 while running_cnt > 0: # 等待事件迴圈通知事件是否已經完成 for key, mask in selector.select(): key.data()
這段程式碼通過Future
和生成器方法儘量的解耦回撥函數, 如果忽略了HttpRequest
中的connected
和read
方法則可以發現整段程式碼跟同步的程式碼基本上是一樣的, 只是通過yield
和yield from
交出控制權和通過事件迴圈恢復控制權。 同時通過上面的異常例子可以發現異常排查非常的方便, 這樣一來就沒有了回撥的各種糟糕的事情, 開發者只需要按照同步的思路進行開發即可, 不過我們的事件迴圈是一個非常簡單的事件迴圈例子, 同時對於socket相關都沒有進行封裝, 也缺失一些常用的API, 而這些都會被Python
官方封裝到Asyncio
這個庫中, 通過該庫, 我們可以近乎完美的編寫Async
語法的程式碼。
NOTE: 由於生成器協程中無法通過
yield from
語法使用生成器, 所以Python
在3.5之後使用了Await
的原生協程。
到此這篇關於Python中Async語法協程的實現的文章就介紹到這了,更多相關Python協程內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!
相關文章
<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
综合看Anker超能充系列的性价比很高,并且与不仅和iPhone12/苹果<em>Mac</em>Book很配,而且适合多设备充电需求的日常使用或差旅场景,不管是安卓还是Switch同样也能用得上它,希望这次分享能给准备购入充电器的小伙伴们有所
2021-06-01 09:31:42
除了L4WUDU与吴亦凡已经多次共事,成为了明面上的厂牌成员,吴亦凡还曾带领20XXCLUB全队参加2020年的一场音乐节,这也是20XXCLUB首次全员合照,王嗣尧Turbo、陈彦希Regi、<em>Mac</em> Ova Seas、林渝植等人全部出场。然而让
2021-06-01 09:31:34
目前应用IPFS的机构:1 谷歌<em>浏览器</em>支持IPFS分布式协议 2 万维网 (历史档案博物馆)数据库 3 火狐<em>浏览器</em>支持 IPFS分布式协议 4 EOS 等数字货币数据存储 5 美国国会图书馆,历史资料永久保存在 IPFS 6 加
2021-06-01 09:31:24
开拓者的车机是兼容苹果和<em>安卓</em>,虽然我不怎么用,但确实兼顾了我家人的很多需求:副驾的门板还配有解锁开关,有的时候老婆开车,下车的时候偶尔会忘记解锁,我在副驾驶可以自己开门:第二排设计很好,不仅配置了一个很大的
2021-06-01 09:30:48
不仅是<em>安卓</em>手机,苹果手机的降价力度也是前所未有了,iPhone12也“跳水价”了,发布价是6799元,如今已经跌至5308元,降价幅度超过1400元,最新定价确认了。iPhone12是苹果首款5G手机,同时也是全球首款5nm芯片的智能机,它
2021-06-01 09:30:45