句柄與指針的區別
學習C++的人都知道句柄和指針,而且我發現很多人在句柄與指針之間直接划等號,對我們來說兩者都是地址,我覺的這也造成很多人將句柄和指針划等號的直接原因。
首先說指針吧。通俗一點就是地址,他是內存的編號,通過它我們可以直接對內存進行操作,只要地址不變,我們每次操作的物理位置是絕對不變,記住這句話,這是句柄和指針的重大區別所在。
再說說句柄吧,一般是指向系統的資源的位置,可以說也是地址。但是這些資源的位置真的不變,我們都知道window支持虛擬內存的技術,同一時間內可能有些資源被換出內存,一些被換回來,這就是說同一資源在系統的不同時刻,他在內存的物理位置是不確定的,那么window是如何解決這個問題呢,就是通過句柄來處理資源的物理位置不斷變化的這個問題的。window會在物理位置固定的區域存儲一張對應表,表中記錄了所有的資源實時地址,句柄其實沒有直接指向資源的物理地址,而是指向了這個對應表中的一項,這樣無論資源怎樣的換進換出,通過句柄都可以找到他的實時位置。
總的來說,通過句柄可以屏蔽系統內部的細節,讓程序設計可以不必考慮操作系統實現的細節。如果還不能理解句柄與指針之間的區別,可以想象指向指針的指針,可以把句柄當作一個指向指針的指針來理解。
文件描述符fd(句柄)
在Linux系統中一切皆可以看成是文件,文件又可分為:普通文件、目錄文件、鏈接文件和設備文件。文件描述符(file descriptor)是內核為了高效管理已被打開的文件所創建的索引,其是一個非負整數(通常是小整數),用於指代被打開的文件,所有執行I/O操作的系統調用都通過文件描述符。
在linux系統中,設備也是以文件的形式存在,要對該設備進行操作就必須先打開這個文件,打開文件就會獲得文件描述符,它是個很小的正整數。每個進程在PCB(Process Control Block)中保存着一份文件描述符表,文件描述符就是這個表的索引,每個表項都有一個指向已打開文件的指針。文件描述符的優點:兼容POSIX標准,許多Linux和UNIX系統調用都依賴於它。文件描述符的缺點:不能移植到UNIX以外的系統上去,也不直觀。
pipe
在Linux上,使用POSIX的condition+mutex,也能完成線程間通知,但是這種方式在linux中用得相對較少,而是大量使用pipe,創建兩個fd(writeFD,readFD)。當線程1想喚醒線程2的時候,就可以往writeFD中寫數據,這樣線程2阻塞在readFD中就能返回。
我之前及其沒搞明白為何要使用pipe,后來突然想明白了。因為Linux上阻塞的方法就是用select,poll和epoll,其中等待的都是FD,那么采用FD這種方式,能夠統一調用方法。
Linux上阻塞的方法
首先我們來定義流的概念,一個流可以是文件,socket,pipe等等可以進行I/O操作的內核對象。
不管是文件,還是套接字,還是管道,我們都可以把他們看作流。
之后我們來討論I/O的操作,通過read,我們可以從流中讀入數據;通過write,我們可以往流寫入數據。現在假定一個情形,我們需要從流中讀數據,但是流中還沒有數據,(典型的例子為,客戶端要從socket讀如數據,但是服務器還沒有把數據傳回來),這時候該怎么辦?
-
阻塞:阻塞是個什么概念呢?比如某個時候你在等快遞,但是你不知道快遞什么時候過來,而且你沒有別的事可以干(或者說接下來的事要等快遞來了才能做);那么你可以去睡覺了,因為你知道快遞把貨送來時一定會給你打個電話(假定一定能叫醒你)。
-
非阻塞忙輪詢:接着上面等快遞的例子,如果用忙輪詢的方法,那么你需要知道快遞員的手機號,然后每分鍾給他掛個電話:"你到了沒?"
很明顯一般人不會用第二種做法,不僅顯很無腦,浪費話費不說,還占用了快遞員大量的時間。
大部分程序也不會用第二種做法,因為第一種方法經濟而簡單,經濟是指消耗很少的CPU時間,如果線程睡眠了,就掉出了系統的調度隊列,暫時不會去瓜分CPU寶貴的時間片了。
緩沖區
為了了解阻塞是如何進行的,我們來討論緩沖區,以及內核緩沖區,最終把I/O事件解釋清楚。
緩沖區的引入是為了減少頻繁I/O操作而引起頻繁的系統調用(你知道它很慢的),當你操作一個流時,更多的是以緩沖區為單位進行操作,這是相對於用戶空間而言。對於內核來說,也需要緩沖區。
假設有一個管道,進程A為管道的寫入方,B為管道的讀出方。
-
假設一開始內核緩沖區是空的,B作為讀出方,被阻塞着。然后首先A往管道寫入,這時候內核緩沖區由空的狀態變到非空狀態,內核就會產生一個事件告訴B該醒來了,這個事件姑且稱之為" 緩沖區非空"。
-
但是" 緩沖區非空"事件通知B后,B卻還沒有讀出數據;且內核許諾了不能把寫入管道中的數據丟掉這個時候,A寫入的數據會滯留在內核緩沖區中,如果內核也緩沖區滿了,B仍未開始讀數據,最終內核緩沖區會被填滿,這個時候會產生一個I/O事件,告訴進程A,你該等等(阻塞)了,我們把這個事件定義為" 緩沖區滿"。
-
假設后來B終於開始讀數據了,於是內核的緩沖區空了出來,這時候內核會告訴A,內核緩沖區有空位了,你可以從長眠中醒來了,繼續寫數據了,我們把這個事件Y1叫做" 緩沖區非滿"。
-
也許事件Y1已經通知了A,但是A也沒有數據寫入了,而B繼續讀出數據,直到內核緩沖區空了。這個時候內核就告訴B,你需要阻塞了!,我們把這個時間定為" 緩沖區空"。
這四個情形涵蓋了四個I/O事件,緩沖區滿,緩沖區空,緩沖區非空,緩沖區非滿(注:都是說的內核緩沖區,且這四個術語都是我生造的,僅為解釋其原理而造)。這四個I/O事件是進行阻塞同步的根本。
select/poll
然后我們來說說阻塞I/O的缺點。阻塞I/O模式下,一個線程只能處理一個流的I/O事件。如果想要同時處理多個流,要么多進程(fork),要么多線程(pthread_create),很不幸這兩種方法效率都不高。
於是再來考慮非阻塞忙輪詢的I/O方式,我們發現我們可以同時處理多個流了(把一個流從阻塞模式切換到非阻塞模式再此不予討論):
while true { for i in stream[]; { if i has data read until unavailable } }
我們只要不停的把所有流從頭到尾問一遍,又從頭開始。這樣就可以處理多個流了,但這樣的做法顯然不好,因為如果所有的流都沒有數據,那么只會白白浪費CPU。這里要補充一點,阻塞模式下,內核對於I/O事件的處理是阻塞或者喚醒,而非阻塞模式下則把I/O事件交給其他對象(后文介紹的select以及epoll)處理甚至直接忽略。
為了避免CPU空轉,可以引進了一個代理(一開始有一位叫做select的代理,后來又有一位叫做poll的代理,不過兩者的本質是一樣的)。這個代理比較厲害,可以同時觀察許多流的I/O事件,在空閑的時候,會把當前線程阻塞掉,當有一個或多個流有I/O事件時,就從阻塞態中醒來,於是我們的程序就會輪詢一遍所有的流(於是我們可以把"忙"字去掉了)。代碼長這樣:
while true { select(streams[]) for i in streams[] { if i has data read until unavailable } }
於是,如果沒有I/O事件產生,我們的程序就會阻塞在select處。但是依然有個問題,我們從select那里僅僅知道了,有I/O事件發生了,但卻並不知道是那幾個流(可能有一個,多個,甚至全部),我們只能無差別輪詢所有流,找出能讀出數據,或者寫入數據的流,對他們進行操作。
但是使用select,我們有O(n)的無差別輪詢復雜度,同時處理的流越多,每一次無差別輪詢時間就越長。再次說了這么多,終於能好好解釋epoll了。
epoll
epoll可以理解為event poll,不同於忙輪詢和無差別輪詢,epoll會把哪個流發生了怎樣的I/O事件通知我們。此時我們對這些流的操作都是有意義的。(復雜度降低到了O(1))
epoll支持四類事件,分別是EPOLLIN(句柄可讀)、EPOLLOUT(句柄可寫),EPOLLERR(句柄錯誤)、EPOLLHUP(句柄斷)。
在討論epoll的實現細節之前,先把epoll的相關操作列出:
-
epoll_create 創建一個epoll對象,一般epollfd = epoll_create()
-
epoll_ctl (epoll_add/epoll_del的合體),往epoll對象中增加/刪除某一個流的某一個事件
-
epoll_ctl(epollfd, EPOLL_CTL_ADD, socket, EPOLLIN);//注冊緩沖區非空事件,即有數據流入
-
epoll_ctl(epollfd, EPOLL_CTL_DEL, socket, EPOLLOUT);//注冊緩沖區非滿事件,即流可以被寫入
-
-
epoll_wait(epollfd,...)等待直到注冊的事件發生
(注:當對一個非阻塞流的讀寫發生緩沖區滿或緩沖區空,write/read會返回-1,並設置errno=EAGAIN。而epoll只關心緩沖區非滿和緩沖區非空事件)。
一個epoll模式的代碼大概的樣子是:
while true { active_stream[] = epoll_wait(epollfd) for i in active_stream[] { read or write till } }
限於篇幅,我只說這么多,以揭示原理性的東西,至於epoll的使用細節,請參考man和google,實現細節,請參閱linux kernel source。
1.Android中為什么主線程不會因為Looper.loop()里的死循環卡死?
這里涉及線程,先說說說進程/線程,進程:每個app運行時前首先創建一個進程,該進程是由Zygote fork出來的,用於承載App上運行的各種Activity/Service等組件。進程對於上層應用來說是完全透明的,這也是google有意為之,讓App程序都是運行在Android Runtime。大多數情況一個App就運行在一個進程中,除非在AndroidManifest.xml中配置Android:process屬性,或通過native代碼fork進程。
線程:線程對應用來說非常常見,比如每次new Thread().start都會創建一個新的線程。該線程與App所在進程之間資源共享,從Linux角度來說進程與線程除了是否共享資源外,並沒有本質的區別,都是一個task_struct結構體,在CPU看來進程或線程無非就是一段可執行的代碼,CPU采用CFS調度算法,保證每個task都盡可能公平的享有CPU時間片。
有了這么准備,再說說死循環問題:
對於線程既然是一段可執行的代碼,當可執行代碼執行完成后,線程生命周期便該終止了,線程退出。而對於主線程,我們是絕不希望會被運行一段時間,自己就退出,那么如何保證能一直存活呢?簡單做法就是可執行代碼是能一直執行下去的,死循環便能保證不會被退出,例如,binder線程也是采用死循環的方法,通過循環方式與不同Binder驅動進行讀寫操作,當然並非簡單地死循環,無消息時會休眠。但這里可能又引發了另一個問題,既然是死循環又如何去處理其他事務呢?通過創建新線程的方式。
真正會卡死主線程的操作是在回調方法onCreate/onStart/onResume等操作時間過長,會導致掉幀,甚至發生ANR,looper.loop本身不會導致應用卡死。
ActivityThread的main源碼:
public static void main(String[] args) { ......(省略) Looper.prepareMainLooper(); ActivityThread thread = new ActivityThread(); thread.attach(false); if (sMainThreadHandler == null) { sMainThreadHandler = thread.getHandler(); } if (false) { Looper.myLooper().setMessageLogging(new LogPrinter(Log.DEBUG, "ActivityThread")); } // End of event ActivityThreadMain. Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER); Looper.loop();// 會死循環,從而使主線程一直運行下去 throw new RuntimeException("Main thread loop unexpectedly exited"); }
2.沒看見哪里有相關代碼為這個死循環准備了一個新線程去運轉?
事實上,會在進入死循環之前便創建了新binder線程,在代碼ActivityThread.main()中:
public static void main(String[] args) { .... //創建Looper和MessageQueue對象,用於處理主線程的消息 Looper.prepareMainLooper(); //創建ActivityThread對象 ActivityThread thread = new ActivityThread(); //建立Binder通道 (創建新線程) thread.attach(false); Looper.loop(); //消息循環運行 throw new RuntimeException("Main thread loop unexpectedly exited"); }
thread.attach(false);便會創建一個Binder線程(具體是指ApplicationThread,Binder的服務端,用於接收系統服務AMS發送來的事件),該Binder線程通過Handler將Message發送給主線程,具體過程可查看 startService流程分析,這里不展開說,簡單說Binder用於進程間通信,采用C/S架構。關於binder感興趣的朋友,可查看我回答的另一個知乎問題:
為什么Android要采用Binder作為IPC機制? - Gityuan的回答
另外,ActivityThread實際上並非線程,不像HandlerThread類,ActivityThread並沒有真正繼承Thread類,只是往往運行在主線程,給人以線程的感覺,其實承載ActivityThread的主線程就是由Zygote fork而創建的進程。
3.主線程的死循環一直運行是不是特別消耗CPU資源呢?
其實不然,這里就涉及到Linux pipe/epoll機制,簡單說就是在主線程的MessageQueue沒有消息時,便阻塞在loop的queue.next()中的nativePollOnce()方法里,詳情見Android消息機制1-Handler(Java層),此時主線程會釋放CPU資源進入休眠狀態,直到下個消息到達或者有事務發生,通過往pipe管道寫端寫入數據來喚醒主線程工作。這里采用的epoll機制,是一種IO多路復用機制,可以同時監控多個描述符,當某個描述符就緒(讀或寫就緒),則立刻通知相應程序進行讀或寫操作,本質同步I/O,即讀寫是阻塞的。 所以說,主線程大多數時候都是處於休眠狀態,並不會消耗大量CPU資源。
4.Activity的生命周期是怎么實現在死循環體外能夠執行起來的?
ActivityThread的內部類H繼承於Handler,通過handler消息機制,簡單說Handler機制用於同一個進程的線程間通信。
Activity的生命周期都是依靠主線程的Looper.loop,當收到不同Message時則采用相應措施:
在H.handleMessage(msg)方法中,根據接收到不同的msg,執行相應的生命周期。
比如收到msg=H.LAUNCH_ACTIVITY,則調用ActivityThread.handleLaunchActivity()方法,最終會通過反射機制,創建Activity實例,然后再執行Activity.onCreate()等方法;
再比如收到msg=H.PAUSE_ACTIVITY,則調用ActivityThread.handlePauseActivity()方法,最終會執行Activity.onPause()等方法。 上述過程,我只挑核心邏輯講,真正該過程遠比這復雜。
主線程的消息又是哪來的呢?
當然是App進程中的其他線程通過Handler發送給主線程,請看接下來的內容:
最后,從進程與線程間通信的角度,通過一張圖加深大家對App運行過程的理解:
system_server進程是系統進程,java framework框架的核心載體,里面運行了大量的系統服務,比如這里提供ApplicationThreadProxy(簡稱ATP),ActivityManagerService(簡稱AMS),這個兩個服務都運行在system_server進程的不同線程中,由於ATP和AMS都是基於IBinder接口,都是binder線程,binder線程的創建與銷毀都是由binder驅動來決定的。
App進程則是我們常說的應用程序,主線程主要負責Activity/Service等組件的生命周期以及UI相關操作都運行在這個線程; 另外,每個App進程中至少會有兩個binder線程 ApplicationThread(簡稱AT)和ActivityManagerProxy(簡稱AMP),除了圖中畫的線程,其中還有很多線程,比如signal catcher線程等,這里就不一一列舉。
Binder用於不同進程之間通信,由一個進程的Binder客戶端向另一個進程的服務端發送事務,比如圖中線程2向線程4發送事務;而handler用於同一個進程中不同線程的通信,比如圖中線程4向主線程發送消息。
結合圖說說Activity生命周期,比如暫停Activity,流程如下:
-
線程1的AMS中調用線程2的ATP;(由於同一個進程的線程間資源共享,可以相互直接調用,但需要注意多線程並發問題)
-
線程2通過binder傳輸到App進程的線程4;
-
線程4通過handler消息機制,將暫停Activity的消息發送給主線程;
-
主線程在looper.loop()中循環遍歷消息,當收到暫停Activity的消息時,便將消息分發給ActivityThread.H.handleMessage()方法,再經過方法的調用,最后便會調用到Activity.onPause(),當onPause()處理完后,繼續循環loop下去。