Android Socket 通訊

weixin_33816300發表於2018-07-14

一、Socket

Socket 作為一種通用的技術規範,首次是由 Berkeley 大學在 1983 為 4.2BSD Unix 提供的,後來逐漸演化為 POSIX 標準。Socket API 是由作業系統提供的一個程式設計介面,讓應用程式可以控制使用 socket 技術。
Socket API 不屬於 TCP/IP協議簇,只是作業系統提供的一個是一個對 TCP / IP協議進行封裝 的程式設計呼叫介面,工作在應用層與傳輸層之間:
一個 Socket 包含兩個必要組成部分:

  1. 地址:IP 和埠號組成一隊套接字
  2. 協議:Socket 所用的是傳輸層協議,目前有 TCP、UDP、raw IP

協議

根據傳輸方式不同(即使用的協議不同)可分為三種:
1.Stream Sockets(流套接字)
基於 TCP協議,採用 流的方式 提供可靠的位元組流服務。TCP 協議有以下特點:

  • 面向連線:指的是要使用TCP傳輸資料,必須先建立TCP連線,傳輸完成後釋放連線,就像打電話一樣必須先撥號建立一條連線,打完後掛機釋放連線。
  • 全雙工通訊:即一旦建立了TCP連線,通訊雙方可以在任何時候都能傳送資料。
  • 可靠的:指的是通過TCP連線傳送的資料,無差錯,不丟失,不重複,並且按序到達。
  • 面向位元組流:流,指的是流入到程式或從程式流出的字元序列。簡單來說,雖然有時候要傳輸的資料流太大,TCP報文長度有限制,不能一次傳輸完,要把它分為好幾個資料塊,但是由於可靠性保證,接收方可以按順序接收資料塊然後重新組成分塊之前的資料流,所以TCP看起來就像直接互相傳輸位元組流一樣,面向位元組流。

2.Datagram Sockets(資料包套接字)
基於 UDP協議,採用 資料包文 提供資料打包傳送的服務。UDP 協議有以下特點:

  • 無連線的:和TCP要建立連線不同,UDP傳輸資料不需要建立連線,就像寫信,在信封寫上收信人名稱、地址就可以交給郵局傳送了,至於能不能送到,就要看郵局的送信能力和送信過程的困難程度了。
  • 不可靠的:因為UDP發出去的資料包發出去就不管了,不管它會不會到達,所以很可能會出現丟包現象,使傳輸的資料出錯。
  • 面向報文:資料包文,就相當於一個資料包,應用層交給UDP多大的資料包,UDP就照樣傳送,不會像TCP那樣拆分。
  • 沒有擁塞控制:擁塞,是指到達通訊子網中某一部分的分組數量過多,使得該部分網路來不及處理,以致引起這部分乃至整個網路效能下降的現象,嚴重時甚至會導致網路通訊業務陷入停頓,即出現死鎖現象,就像交通堵塞一樣。TCP建立連線後如果傳送的資料因為通道質量的原因不能到達目的地,它會不斷重發,有可能導致越來越塞,所以需要一個複雜的原理來控制擁塞。而UDP就沒有這個煩惱,發出去就不管了。

3.Row Sockets
通常用在路由器或其他網路裝置中,這種 socket 不經過TCP/IP協議簇中的傳輸層(transport layer),直接由網路層(Internet layer)通向應用層(Application layer),所以這時的資料包就不會包含 tcp 或 udp 頭資訊。
Android網路程式設計:基礎理論彙總

二、Socket 基本用法

1、TCP 伺服器端

protected void TCPServer(){
        try {
            //建立伺服器端 Socket,指定監聽埠
            ServerSocket serverSocket = new ServerSocket(8888);
            //等待客戶端連線
            Socket clientSocket = serverSocket.accept();
            //獲取客戶端輸入流,
            InputStream is = clientSocket.getInputStream();
            InputStreamReader isr = new InputStreamReader(is);
            BufferedReader br = new BufferedReader(isr);
            String data = null;
            //讀取客戶端資料
            while((data = br.readLine()) != null){
                System.out.println("伺服器接收到客戶端的資料:" + data);
            }
            //關閉輸入流
            clientSocket.shutdownInput();
            //獲取客戶端輸出流
            OutputStream os = clientSocket.getOutputStream();
            PrintWriter pw = new PrintWriter(os);
            //向客戶端傳送資料
            pw.print("伺服器給客戶端回應的資料");
            pw.flush();
            //關閉輸出流
            clientSocket.shutdownOutput();
            //關閉資源
            pw.checkError();
            os.close();
            br.close();
            isr.close();
            is.close();
            clientSocket.close();
            serverSocket.close();
        } catch (IOException e) {
            e.printStackTrace();
        }

    }

2、TCP 客戶端

protected void TCPClient(){
        try {
            //建立客戶端Socket,指定伺服器的IP地址和埠
            Socket socket = new Socket(InetAddress.getLocalHost(),8888);
            //獲取輸出流,向伺服器傳送資料
            OutputStream os = socket.getOutputStream();
            PrintWriter pw = new PrintWriter(os);
            pw.write("客戶端給伺服器端傳送的資料");
            pw.flush();
            //關閉輸出流
            socket.shutdownOutput();

            //獲取輸入流,接收伺服器發來的資料
            InputStream is = socket.getInputStream();
            InputStreamReader isr = new InputStreamReader(is);
            BufferedReader br = new BufferedReader(isr);
            String data = null;
            //讀取客戶端資料
            while((data = br.readLine()) != null){
                System.out.println("客戶端接收到伺服器回應的資料:" + data);
            }
            //關閉輸入流
            socket.shutdownInput();

            //關閉資源
            br.close();
            isr.close();
            is.close();
            pw.close();
            os.close();
            socket.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

3、UDP 服務端

protected void UDPServer(){
        try {
            //建立伺服器端 Socket,指定埠
            DatagramSocket socket = new DatagramSocket(8888);
            //建立資料包用於接收客戶端傳送的資料
            byte[] bytes = new byte[1024];
            DatagramPacket packet = new DatagramPacket(bytes,bytes.length);
            //接收客戶端傳送的資料
            socket.receive(packet);
            //讀取資料(也可以呼叫 packet.getData())
            String info = new String(bytes,0,packet.getLength());

            //返回資料
            InetAddress address = packet.getAddress();
            int port = packet.getPort();
            byte[] data = "伺服器返回的資料".getBytes();
            DatagramPacket dataPacket = new DatagramPacket(data,data.length,address,port);
            socket.send(dataPacket);
            //關閉 Socket
            socket.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

4、UDP 客戶端

 protected void UDPClient(){
        try {
            //建立客戶端 Socket
            DatagramSocket socket = new DatagramSocket();
            //建立資料包
            byte[] data = "向伺服器傳送的資料".getBytes();
            InetAddress address = InetAddress.getLocalHost();
            int port = 8888;
            DatagramPacket packet = new DatagramPacket(data,data.length,address,port);
            //傳送資料包
            socket.send(packet);
            
            //接收伺服器響應的資料包
            byte[] info = new byte[1024];
            DatagramPacket infoPacket = new DatagramPacket(info,info.length);
            String receiveInfo = new String(info,0,infoPacket.getLength());
            
            socket.close();
            
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

三、InetAddress 類

InetAddress 是 Java 對 IP 地址的封裝,InetAddress 的例項物件包含以數字形式儲存的 IP 地址,同時還可能包含主機名(如果使用主機名來獲取 InetAddress 的例項,或者使用數字來構造,並且啟用了反向主機名解析的功能)。InetAddress 類提供了將主機名解析為IP地址(或反之)的方法。

InetAddress 物件的獲取

InetAddress的建構函式不是公開的(public),所以需要通過它提供的靜態方法來獲取,有以下的方法:

//返回代表由一個特殊名稱分解的所有地址的InetAddresses類陣列
//在不能把名稱分解成至少一個地址時,它將引發一個UnknownHostException異常。
static InetAddress[] getAllByName(String host)

static InetAddress getByAddress(byte[] addr)

static InetAddress getByAddress(String host,byte[] addr)
//返回一個傳給它的主機名的InetAddress。
//如果這些方法不能解析主機名,它們引發一個UnknownHostException異常。
static InetAddress getByName(String host)
//僅返回象徵本地主機的InetAddress物件,
//本機地址還為localhost,127.0.0.1,這三個地址都是一回事。
static InetAddress getLocalHost()

其它方法:

  • public String getHostName()
    根據建立 InetAddress 物件的不同方式,getHostName 的返回值是不同的。
    用 getLocalHost() 方法建立的 InetAddress 的物件,返回的是本機名;
    用域名作為 getByName 和 getAllByName 方法的引數得到的 InetAddress 物件,該物件會得到這個域名,當呼叫 getHostName 時,就無需再訪問 DNS 伺服器,而是直接將這個域名返回。
    使用IP地址建立 InetAddress 物件(getByName,getAllByName,getByAddress)方法都可以通過 IP 地址建立 InetAddress 物件)時,並不需要訪問 DNS 伺服器。因此,通過 DNS 伺服器查詢域名的工作就由 getHostName 方法來完成。 如果 IP 地址不存在或 DNS 伺服器不允許進行 IP 地址和域名對映,就返回這個 IP 地址。
  • public String getHostAddress()
    該方法用來得到主機的 IP 地址,這個 IP 地址可以是 IPv4 也可以是 IPv6 的
  • public byte[] getAddress()
    該方法和 getHostAddress 方法唯一區別是,getHostAddress 返回字元形式的 IP 地址,getAddress 返回 byte 陣列形式的 IP 地址。

四、URL 類

類 URL 代表一個統一資源定位符,包括協議、主機名
構造方法:

//url 代表一個絕對地址,URL 物件直接指向這個資源
URL ( String url)
//baseURL 代表絕對地址,relativeURL 代表相對地址
URL ( URL baseURL , String relativeURL) 
//protocol 代表通訊協議,host 代表主機名,file 代表檔名
 URL ( String protocol , String host , String file) 
//
 URL ( String protocol , String host , int port , String file) 

常用方法:

  • url1.getHost() 主機
  • getProtocol()協議
  • getPort()埠,如果未指定埠號,則使用預設的埠號,此時 getPort() 方法返回值為-1
  • getPath()檔案路徑
  • getFile()檔名
  • getRef() 相對路徑
  • getContent()內容
  • getQuery() 查詢字串
  • openStream() 通過 URL 的 openStream 方法獲取 URL 物件所表示的資源的位元組輸入流

五、Socket 連線和 Http 連線的關係

Socket 連線一般情況下都是 TCP 連線,因此 Socket 連線一旦建立,通訊雙方就可以進行互相傳送內容。但在實際網路應用中,客戶端到伺服器之間的通訊往往需要穿越多箇中間節點,例如路由器、閘道器、防火牆等,大部分防火牆預設會關閉長時間處於非活躍狀態的連線而導致 Socket 連線斷連,因此需要通過輪詢告訴網路,該連線處於活躍狀態。(這也就是常說的“心跳策略”)
Http連線是 “請求-響應” 的方式,不僅在請求時需要先建立連線,而且需要客戶端向伺服器發出請求後,伺服器端才能回覆資料。

總結:如果建立的是Socket連線,伺服器可以直接將資料傳送給客戶端;如果方建立的是HTTP連線,則伺服器需要等到客戶端傳送一次請求後才能將資料傳回給客戶端。

六、長連線、短連線、輪詢和心跳

在HTTP/1.0中,預設使用的是短連線。但從 HTTP/1.1起,預設使用長連線。

HTTP 是一種應用層的網路協議,長連線是存在於網路層的一種連線狀態,而實現它則需要在傳輸層進行開發。
HTTP 作為應用層協議,其實它的生命週期在伺服器返回結果時就已經結束了,而所謂的支援長連線,其實是基於 'Keep-Alive' 請求頭所約定,從而向下進行長連線發起的一種機制。該長連線依然是基於 TCP 的。

短連線

所謂短連線,即連線只保持在資料傳輸過程,請求發起,連線建立,資料返回,連線關閉。它適用於一些實時資料請求,配合輪詢來進行新舊資料的更替。

長連線

長連線便是在連線發起後,在請求關閉連線前客戶端與服務端都保持連線,不管此時有無資料包的傳送,實質是保持這個通訊管道,之後便可以對其進行復用。
它適用於涉及訊息推送,請求頻繁的場景(直播,流媒體)。連線建立後,在該連線下的所有請求都可以重用這個長連線管道,避免了頻繁了連線請求,提升了效率。
長連線的優勢:

  • 減少連線建立過程的耗時大家都知道TCP連線建立需要三次握手,三次握手也就說需要三次互動才能建立一個連線通道,同城的機器之間的大概是ms級別的延時,影響還不大,如果是北京和上海兩地機房,走專線一來一回大概需要30ms,如果使用長連線,這個優化還是十分可觀的。
  • 方便實現push資料資料互動-推模式實現的前提是網路長連線,有了長連線,連線兩端很方便的互相push資料,來進行互動。

TCP連線在預設的情況下就是所謂的長連線, 也就是說連線雙方都不主動關閉連線, 這個連線就應該一直存在.

長連線怎麼保活?
TCP 協議實現中,是有保活機制的,也就是 TCP 的 KeepAlive 機制(此機制並不是 TCP 協議規範中的內容,由作業系統去實現),KeepAlive 機制開啟後,在一定時間內(一般時間為7200s,引數 tcp_keepalive_time)在鏈路上沒有資料傳送的情況下,TCP層將傳送相應的 KeepAlive 探針以確定連線可用性,探測失敗後重試10(引數 tcp_keepalive_probes)次,每次間隔時間75s(引數 tcp_keepalive_intvl),所有探測失敗後,才認為當前連線已經不可用。這些引數是機器級別,可以調整。
一個可靠的系統,長連線的保活肯定是要依賴應用層的心跳來保證的。這裡應用層的心跳舉個例子,比如客戶端每隔3s通過長連線通道傳送一個心跳請求到服務端,連續失敗5次就斷開連線。這樣算下來最長15s就能發現連線已經不可用,一旦連線不可用,可以重連,也可以做其他的failover處理,比如請求其他伺服器。

心跳

用來檢測一個系統是否存活或者網路鏈路是否通暢的一種方式, TCP 長連線本質上不需要心跳包來維持,其一般做法是定時向被檢測系統傳送心跳包,被檢測系統收到心跳包進行回覆,收到回覆說明對方存活。
心跳能夠給長連線提供保活功能,能夠檢測長連線是否正常(這裡所說的保活不能簡單的理解為保證活著,具體來說應該是一旦鏈路死了,不可用了,能夠儘快知道,然後做些其他的高可用措施,來保證系統的正常執行)。
被連線方檢測心跳的實現分為心跳的傳送和心跳的檢測,心跳由誰來發都可以,也可以雙方都傳送,但是檢測心跳,必須由發起連線的這端進行,才安全。因為只有發起連線的一端檢測心跳,知道鏈路有問題,這時才會去斷開連線,進行重連,或者重連到另一臺伺服器。

輪詢

所謂輪詢,即是在一個迴圈週期內不斷髮起請求來得到資料的機制。只要有請求的的地方,都可以實現輪詢,譬如各種事件驅動模型。它的長短是在於某次請求的返回週期。

  • 短輪詢:短輪詢指的是在迴圈週期內,不斷髮起請求,每一次請求都立即返回結果,根據新舊資料對比決定是否使用這個結果。
  • 長輪詢:而長輪詢即是在請求的過程中,若是伺服器端資料並沒有更新,那麼則將這個連線掛起,直到伺服器推送新的資料,再返回,然後再進入迴圈週期。

輪詢是為了獲取資料, 而心跳是為了保活TCP連線.

由上可以看到,長短輪詢的理想實現都應當基於長連線

UDP 廣播

地址:廣播地址是由 IP 地址和子網掩碼(兩者都是4位元組)計算出來的。子網掩碼的二進位制形式是高 N 位1和低 (32-N) 位0。IP 地址與子網掩碼進行按位與操作後得到網路號,網路號相同的 IP 地址認為在同一網段。子網掩碼的所有位取反後,與網路號進行同或操作,就是廣播地址了。

UDP 資料包長度:

udp 的最大包長度是 2^16-1 的個位元組。由於 udp 包頭佔8個位元組,而在 ip 層進行封裝後的 ip 包頭佔去20位元組,所以這個是 udp 資料包的最大理論長度是2^16-1-8-20=65507。然而這個只是 udp 資料包的最大理論長度,UDP 屬於運輸層,在傳輸過程中,udp 包的整體是作為下層協議的資料欄位進行傳輸的,它的長度大小受到下層 ip 層和資料鏈路層協議的制約。

MTU

乙太網(Ethernet)資料幀的長度必須在46-1500位元組之間,這是由乙太網的物理特性決定的。這個1500位元組被稱為鏈路層的 MTU (最大傳輸單元)。
因特網協議允許 IP 分片,這樣就可以將資料包分成足夠小的片段以通過那些最大傳輸單元小於該資料包原始大小的鏈路了。這一分片過程發生在網路層,它使用的是將分組傳送到鏈路上的網路介面的最大傳輸單元的值。
對於大於這個數值的分組可能被分片,否則無法傳送,而分組交換的網路是不可靠的,存在著丟包。不超過MTU的分組是不存在分片問題的。
MTU的值並不包括鏈路層的首部和尾部的18個位元組。所以,這個1500位元組就是網路層IP資料包的長度限制。因為IP資料包的首部為20位元組,所以IP資料包的資料區長度最大為1480位元組。而這個1480位元組就是用來放TCP傳來的TCP報文段或UDP傳來的UDP資料包的。又因為UDP資料包的首部8位元組,所以UDP資料包的資料區最大長度為1472位元組。這個1472位元組就是我們可以使用的位元組數。
因為 Internet 上的路由器可能會將 MTU 設為不同的值。如果我們假定 MTU 為1500來傳送資料的,而途經的某個網路的 MTU 值小於1500位元組,那麼系統將會使用一系列的機制來調整 MTU 值,使資料包能夠順利到達目的地。鑑於Internet上的標準 MTU 值為576位元組,所以在進行 Internet 的 UDP 程式設計時,最好將 UDP 的資料長度控制元件在548位元組(576-8-20)以內。

UDP丟包

udp 丟包是指網路卡接收到資料包後,linux 核心的 tcp/ip 協議棧在 udp 資料包處理過程中的丟包,主要原因有兩個:

  • udp資料包格式錯誤或校驗和檢查失敗。
  • 應用程式來不及處理udp資料包。
    通用的udp丟包檢測方法,使用netstat命令,加-su引數。
Udp:

    2495354 packets received

    2100876 packets to unknown port received.

    3596307 packet receive errors

    14412863 packets sent

    RcvbufErrors: 3596307

    SndbufErrors: 0

從上面的輸出中,可以看到有一行輸出包含了"packet receive errors",如果每隔一段時間執行 netstat -su,發現行首的數字不斷變大,表明發生了udp丟包。
應用程式來不及處理而導致udp丟包的常見原因:
1)linux核心socket緩衝區設的太小
cat /proc/sys/net/core/rmem_default
cat /proc/sys/net/core/rmem_max
可以檢視socket緩衝區的預設值和最大值。
rmem_default和rmem_max設定為多大合適呢?如果伺服器的效能壓力不大,對處理時延也沒有很嚴格的要求,設定為1M左右即可。如果伺服器的效能壓力較大,或者對處理時延有很嚴格的要求,則必須謹慎設定rmem_default 和rmem_max,如果設得過小,會導致丟包,如果設得過大,會出現滾雪球。
2)伺服器負載過高,佔用了大量cpu資源,無法及時處理linux核心socket緩衝區中的udp資料包,導致丟包。
一般來說,伺服器負載過高有兩個原因:收到的udp包過多;伺服器程式存在效能瓶頸。如果收到的udp包過多,就要考慮擴容了。伺服器程式存在效能瓶頸屬於效能優化的範疇,這裡不作過多討論。
3)磁碟IO忙
伺服器有大量IO操作,會導致程式阻塞,cpu都在等待磁碟IO,不能及時處理核心socket緩衝區中的udp資料包。如果業務本身就是IO密集型的,要考慮在架構上進行優化,合理使用快取降低磁碟IO。
4)實體記憶體不夠用,出現swap交換
swap交換本質上也是一種磁碟IO忙,因為比較特殊,容易被忽視,所以單列出來。
只要規劃好實體記憶體的使用,並且合理設定系統引數,可以避免這個問題。
5)磁碟滿導致無法IO
沒有規劃好磁碟的使用,監控不到位,導致磁碟被寫滿後伺服器程式無法IO,處於阻塞狀態。最根本的辦法是規劃好磁碟的使用,防止業務資料或日誌檔案把磁碟塞滿,同時加強監控,例如開發一個通用的工具,當磁碟使用率達到80%時就持續告警,留出充足的反應時間。

  • 接收端處理時間過長導致丟包:呼叫recv方法接收端收到資料後,處理資料花了一些時間,處理完後再次呼叫recv方法,在這二次呼叫間隔裡,發過來的包可能丟失。對於這種情況可以修改接收端,將包接收後存入一個緩衝區,然後迅速返回繼續recv。
  • 傳送的包巨大丟包:雖然send方法會幫你做大包切割成小包傳送的事情,但包太大也不行。例如超過50K的一個udp包,不切割直接通過send方法傳送也會導致這個包丟失。這種情況需要切割成小包再逐個send。
  • 傳送的包較大,超過接受者快取導致丟包:包超過mtu size數倍,幾個大的udp包可能會超過接收者的緩衝,導致丟包。這種情況可以設定socket接收緩衝。以前遇到過這種問題,我把接收緩衝設定成64K就解決了。
int nRecvBuf=32*1024;//設定為32K
setsockopt(s,SOL_SOCKET,SO_RCVBUF,(const char*)&nRecvBuf,sizeof(int));
  • 傳送的包頻率太快:雖然每個包的大小都小於mtu size 但是頻率太快,例如40多個mut size的包連續傳送中間不sleep,也有可能導致丟包。這種情況也有時可以通過設定socket接收緩衝解決,但有時解決不了。所以在傳送頻率過快的時候還是考慮sleep一下吧。
  • 區域網內不丟包,公網上丟包。這個問題我也是通過切割小包並sleep傳送解決的。如果流量太大,這個辦法也不靈了。總之udp丟包總是會有的,如果出現了用我的方法解決不了,還有這個幾個方法: 要麼減小流量,要麼換tcp協議傳輸,要麼做丟包重傳的工作。
    UDP主要丟包原因及具體問題分析

相關文章