一. 爲什麼想到用組播
單臺設備又要進行音視頻數據的採集,同時又要擔負對多臺加入的設備進行實時推流的能力。顯然在內存及併發推流能力上很快就已經進入到一個瓶頸了。
在用TCP進行推流的代碼實現後,在實際的的測試過程中,在清晰度要求不下降的情況下,連接設置超過20臺之後就開始出現明顯的卡頓現象了。
因爲推流端基於Extension(應用擴展)方式實現,在內存使用上不能超過50兆。所以不可能每個連接都開闢一份獨立的發送緩存進行發送數據的緩充(在實現的前期,其實是實現了這樣一套邏輯,這樣可以保證每個連接上數據發送的連貫性,但在後期的上機測試後,不到幾分鐘就輕鬆越過了50兆內存的紅線。可以預測到的原因是,數據發送的速度遠遠低於數據生成的速度,另一方面隨着連接數的增加,重複緩存的數據不斷增多,發送的效率不斷下降)。
所以在實現上,當數據生成並封包完成後,會分別以異步的方式調起系統層發送緩存,分別向各個接收端進行數據的發送。代碼實現上是相對簡單的,但總有一種不靠譜的感覺。但直得慶幸的是,令人頭痛的內存問題終於解決了。在代碼實現後進行真機測試時,因爲成功把內存壓力傳遞到了系統層,測試得出“應用擴展”的消耗內存幾乎一直處於一個非常穩定的水平,在20臺設置併發推流的情況下,內存穩步在10兆以下。
但受限於當前發送端數據發送的能力及可手帶寬有限,在測試過程中發現部分接收設備出現顯示卡頓現象。但會議投屏對於實時播放清晰度的硬性要求。在處理數據發送的併發性問題上我陷入了思考。
解決方案一:建立一箇中間推流服務,推流數據端以單一的TCP連接方式與“推流服務器”建立連接。數據接收端以訂閱的方式與“推流服務器”建立多對一的連接。“推流服務器”分別對多個接收端進行推流。一方面“推流服務器”無論在內存還是數據處理能力上都比一臺手機平板要好上多個數量級。另一方面網絡傳輸能力上也是遠遠無法比擬的。當要人數達到一定數量的情況下,還可以對“推流服務器”進行擴展或以 CDN的方式進行分流,玩法真是多種多樣。
這個方案其實也是項目一開始我就提出來的,但基於諸多原因,第一個版本先簡實現。於是就有了方案二的出現。
解決方案二:用UDP + 組播的方式進行組播範圍內推流。一方面解決了推流端的併發性問題,同時也不會產生內存問題。接收端只要監聽指定的組播IP,打開數據接收邏輯,即可實現組播內數據的複製與共享。推流端只要單一向指定的組播IP發送數據,對於組播內接入的接收端數量,推流端不受其影響。
二. 對數據進行分片操作
上一章已經對UDP的數據格式做一簡單說明,而去到發送前,必不可少的是要對數據進行分片操作,以保證每個發出去的UDP報文:
- 不會超過通訊網絡的MTU值。
- 不會引起“網絡層”對數據進行二次分片處理。
不觸發“網絡層”的二次分片更有利於後面對UDP進行丟包處理,這個也是應用層主動分片的另一個很重要的原因。當然第一個原因就是sendto函數本來對單次調用發送的數據有數量限制,詳細可參考上一篇文章。
對於分包邏輯,每個人都可以有不同的實現方式,在不考慮流量控制等情況下,下面給出簡單的代碼以供參考:
#pragma mark- 對【發送數據】進行udp分片
+ (NSMutableArray*)toSplitPackage:(NSData*)bodyData {
if(bodyData != nil){
//數據包存儲隊列
NSMutableArray *packageArray = [[NSMutableArray alloc] init];
int packageID = [self identity]; //數據包id
int count = ((int)bodyData.length) / PACKAGE_MTU; //整除數量
int otherElse = bodyData.length % PACKAGE_MTU > 0? 1 : 0; //整除餘數
int totalSize = (int)bodyData.length; //數據包大小
if(count == 0){
UDPFragment *fragment = [[UDPFragment alloc] init];
fragment.packageID = packageID;
fragment.packageSize = totalSize;
fragment.fragmentCount = count + otherElse; //有【餘數】,整除數量+1
fragment.fragmentIndex = 0; //index 從0開始
fragment.fragmentType = 1; //分片數據類型 1: 數據, 2: fec
fragment.fragmentSize = totalSize; //少於mtu值,value = total_size
fragment.data = bodyData;
[packageArray addObject:fragment];
}else{
// 1.處理整除
for (int i = 0; i < count; i++){
UDPFragment *fragment = [[UDPFragment alloc] init];
fragment.packageID = packageID;
fragment.packageSize = totalSize;
fragment.fragmentCount = count + otherElse; //有【餘數】,整除數量+1
fragment.fragmentIndex = i; //index 從0開始
fragment.fragmentType = 1; //分片數據類型 1: 數據, 2: fec
fragment.fragmentSize = PACKAGE_MTU; //vaule = mtu
fragment.data = [bodyData subdataWithRange:NSMakeRange(i * PACKAGE_MTU, PACKAGE_MTU)];
[packageArray addObject:fragment];
}
// 2.處理佘數
int reCount = bodyData.length % PACKAGE_MTU;
if(reCount > 0){
UDPFragment *fragment = [[UDPFragment alloc] init];
fragment.packageID = packageID;
fragment.packageSize = totalSize;
fragment.fragmentCount = count + otherElse; //有【餘數】,整除數量+1
fragment.fragmentIndex = count; //index 從0開始
fragment.fragmentType = 1; //分片數據類型 1: 數據, 2: fec
fragment.fragmentSize = reCount; //存在餘數,最後一個包爲【餘數值】
fragment.data = [bodyData subdataWithRange:NSMakeRange((count) * PACKAGE_MTU, reCount)];
[packageArray addObject:fragment];
}
}
return packageArray;
}
return nil;
}
- 對於數據包的唯一標識,可以各自定義。參考大多數通訊協議的生成規則,唯一標識可循環重用。
- 對於最後一個數據包少於 PACKAGE_MTU 數值的包,不進行補碼處理,如是少於發送最少數值也只留給“網絡層”做補碼處理。
三. 組播推流端發送邏輯
在實際的應用中組播地址動態生成,動態分配,動態管理會是更好的方式。以會議投屏項目爲例,推流端先在登記服務中建立會議房間並設置訪問密碼,服務端生成併成功分配組播地址返回到推流端。接收端在登記服中選擇自己想進入的會議房間,輸入訪問密碼併成功驗證後,反回房間對應的組播地址到接收端。接收端監聽組播數據並處理顯示播放等。在後期的文章中,會針對服務端的實現給出具體的代碼及實現方式,現在就只簡單討論手機端的實現。
- 創建目標句柄。
// -------------- 1. 創建socket對象 -------------
self.socketFD = socket(AF_INET, SOCK_DGRAM, 0);
if (self.socketFD == -1) {
perror("socket: error\n");
return;
}
// -------------- 2. 向服務端地址發送數據廣播:--------------
bzero(&m_serveraddr, sizeof(m_serveraddr));
m_serveraddr.sin_family = AF_INET;//設置IPv4
m_serveraddr.sin_addr.s_addr = inet_addr(MCAST_ADDR);
m_serveraddr.sin_port = htons(MCAST_PORT);
- 實現數據報發送邏輯
#pragma mark- send udp package
- (NSUInteger)sendData:(NSData*)data{
if (data != nil) {
const char *send_Message = [data bytes];
int sendLen = 0;
while (sendLen < data.length) {
ssize_t size = sendto(self.socketFD, send_Message, data.length, 0, (struct sockaddr*)&m_serveraddr, sizeof(m_serveraddr));
NSLog(@"*** 發送了%ld個字符\n ***", size);
sendLen += size;
send_Message += size;
}
return data.length;
}
return 0;
}
四. 組播接收端監聽
- 建立目錄句柄
/* 1. 初始化地址*/
int ret = -1;
m_sockfd = socket(AF_INET, SOCK_DGRAM, 0); // *** SOCK_DGRAM -> UDP ****
if (m_sockfd < 0) {
perror("sockfd error :");
return -1;
}
/* 2. 綁定socket*/
struct sockaddr_in local_addr;
local_addr.sin_family = AF_INET;
local_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 也可直接 = INADDR_BROADCAST
local_addr.sin_port = htons(MCAST_PORT);
ret = bind(m_sockfd, (struct sockaddr *)&local_addr, sizeof(local_addr));
if (ret < 0) {
perror("bind error :");
return -1;
}
/* 3. 設置迴環許可*/
int loop = 1;
ret = setsockopt(m_sockfd, IPPROTO_IP, IP_MULTICAST_LOOP, &loop, sizeof(loop));
if (ret < 0) {
perror("set multicast_loop error :");
return -1;
}
/* 4. 將本機加入廣播組*/
struct ip_mreq mreq ={0};
mreq.imr_multiaddr.s_addr = inet_addr(MCAST_ADDR);//廣播地址
mreq.imr_interface.s_addr = htonl(INADDR_ANY);//網絡接口爲默認
ret = setsockopt(m_sockfd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq));
if (ret < 0) {
perror("add_membership error :");
return -1;
}
- 監聽並接收數據
bzero(receive, bufferSize);
socklen_t addrlen = sizeof(m_clientaddr);
long re = recvfrom(m_sockfd, receive, sizeof(receive), 0, (struct sockaddr*)&m_clientaddr, (socklen_t*)&addrlen);
if (re > 0){
if (hadAction == YES) {
NSData *data = [[NSData alloc] initWithBytes:receive length:re];
[self.delegate onReceiveData:data];
}
}
五. 組播接收端分片緩存及數據包重組
//1.獲取/生成【數據包】對象
NSNumber *key = [NSNumber numberWithInt:fragment.packageID];
if ([self.packageMap.allKeys containsObject:key] == YES){
target = [self.packageMap objectForKey:key];
}else{
UDPPackage *info = [[UDPPackage alloc] init];
info.packageID = fragment.packageID;
info.fragmentCount = fragment.fragmentCount;
info.packageSize = fragment.packageSize;
info.groudLength = fragment.groupLength;
[self.packageMap setObject:info forKey:key];
target = info;
}
//2.【數據包】中進行"分片去重"
Boolean hadIn = NO;
for (UDPFragment *item in target.fragmentBuffer){
if (item.fragmentIndex == fragment.fragmentIndex){
hadIn = YES;
break;
}
}
if (hadIn == NO) {
//3.分片放到【數據包】中
[target.fragmentBuffer addObject:fragment];
//4. 進行丟包監測, 及FEC操作
[self checkLose:target package:fragment];
//5. 重組檢測
if(target.fragmentCount == target.fragmentBuffer.count){
[self onRegroupAction:target]; //包已經收完, 對包進行合併
[self.packageMap removeObjectForKey:key]; //放到合併隊列後,回收緩存
self.lastUDPPackage = nil;
}
}
Demo實現上的大概流程如下:
- 建立 / 獲取 包id對應的分片緩存。
- 對接收的分片進行去重操作。在實際的運行測試中發現重複發送在一定程度上是存在的。
- 緩存接收的分片數據
- 丟包監測及處理操作。這個在後面的文章中會介紹到。
- 數據包重組。當監測接收到的數據分片已經具備重組條件時,會對數據包進行重組並回調到處理上層進行播放顯示。
六. 解決方案三
解決方案三結合了方案一和方案二各自的優勢,一方面可解決推流數據可實現服務端緩存,有利於丟包反饋時可實現部分分片單獨重發(採用TCP信道)。另一方面可有效差輕轉發服務的負擔,同樣採用組播的方式,把數據發送到組播網絡實現數據共享。
在後其的實現文章中會介紹到相關的實現。