iOS如何搭建一个HTTP服务器

bonjour

https://developer.apple.com/bonjour/
Bonjour, also known as zero-configuration networking, enables automatic discovery of devices and services on a local network using industry standard IP protocols. Bonjour makes it easy to discover, publish, and resolve network services with a sophisticated, easy-to-use programming interface that is accessible from Cocoa, Ruby, Python, and other languages.

iOS Socket编程

https://www.jianshu.com/p/a019b582204a

客户端Socket

客户端Socket主要分为四步。
首先,创建Socket,如果创建成功我们会得到一个Socket的文件描述符,方便我们后续获取这个Socket。
其次,我们需要对我们想要访问的IP和端口发起connect请求
然后,如果connect请求成功,我们开始read、write操作,如果请求不成功,我们要考虑重连机制
对于读操作,我们可以通过设置flag去设置读的方式,当flag是0时,我们是阻塞读的方式,即服务器有向服务端发送消息我们才会去读。
对于写操作,当我们想像服务端发送时则调用send。
客户端和服务端的后续交互,就是通过不停的read和write操作来进行的。

#import "SocketClient.h"
#import <sys/socket.h>
#import <netinet/in.h>
#import <arpa/inet.h>

//htons : 将一个无符号短整型的主机数值转换为网络字节顺序,不同cpu 是不同的顺序 (big-endian大尾顺序 , little-endian小尾顺序)
#define SocketPort htons(8040) //端口
//inet_addr是一个计算机函数,功能是将一个点分十进制的IP转换成一个长整数型数
#define SocketIP   inet_addr("127.0.0.1") // ip
@interface SocketClient ()
//属性,用于接收socket创建成功后的返回值
@property (nonatomic, assign) int clinenId;
@property (nonatomic, assign) BOOL socketReady;
@end

struct sockaddr_in socketAddr;
struct in_addr  socketIn_addr;



@implementation SocketClient
//如果连接失败,比如服务端达到了最大连接数或者服务端端开了连接,那我们客户端需要自己去实现重连机制
- (void)startClient{
    [self buildSocket];
    if (_clinenId != -1) {
        if([self connectSocket] == 0){
            _socketReady = YES;
            // 调用开始接受信息的方法
            // while 如果主线程会造成堵塞
            dispatch_async(dispatch_get_global_queue(0, 0), ^{
                [self messgeRecieve];
            });
        }
    }
}

- (void)buildSocket{
    //int socket(int domain, int type, int protocol);
    //创建一个socket结构体,在unix系统中,一切皆文件,这个返回的id就是文件描述符fd,创建成功之后,我们可以通过fd来访问我们的socket
    /*domain:协议域,又称协议族(family)。常用的协议族有AF_INET(ipv4)、AF_INET6(ipv6)、AF_LOCAL(或称AF_UNIX,Unix域Socket)、AF_ROUTE等。协议族决定了socket的地址类型,在通信中必须采用对应的地址,如AF_INET决定了要用ipv4地址(32位的)与端口号(16位的)的组合、AF_UNIX决定了要用一个绝对路径名作为地址。

    type:指定Socket类型。常用的socket类型有SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等。流式Socket(SOCK_STREAM)是一种面向连接的Socket,针对于面向连接的TCP服务应用。数据报式Socket(SOCK_DGRAM)是一种无连接的Socket,对应于无连接的UDP服务应用。

    protocol:指定协议。常用协议有IPPROTO_TCP、IPPROTO_UDP、IPPROTO_STCP、IPPROTO_TIPC等,分别对应TCP传输协议、UDP传输协议、STCP传输协议、TIPC传输协议。

    注意:type和protocol不可以随意组合,如SOCK_STREAM不可以跟IPPROTO_UDP组合。当第三个参数为0时,会自动选择第二个参数类型对应的默认协议。

    返回值:如果调用成功就返回新创建的套接字的描述符,如果失败就返回INVALID_SOCKET(Linux下失败返回-1)。套接字描述符是一个整数类型的值*/
    _clinenId = socket(AF_INET, SOCK_STREAM, 0);
        
    if (_clinenId == -1) {
        NSLog(@"创建socket 失败");
        return;
    }
    
    /**
     __uint8_t    sin_len;          假如没有这个成员,其所占的一个字节被并入到sin_family成员中
     sa_family_t    sin_family;     一般来说AF_INET(地址族)PF_INET(协议族)
     in_port_t    sin_port;         // 端口
     struct    in_addr sin_addr;    // ip
     char        sin_zero[8];       没有实际意义,只是为了 跟SOCKADDR结构在内存中对齐
     */
    socketAddr.sin_family   = AF_INET;//当前这个是ipv4
    socketAddr.sin_port     = SocketPort; //这里定义了一个宏

    socketIn_addr.s_addr    = SocketIP; // 也是宏
    socketAddr.sin_addr     = socketIn_addr;
}

- (NSInteger)connectSocket{
    //int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
//第一个参数是socket文件描述符,第二个参数是socket结构体地址,第三个参数是socket地址结构体的大小,这是C风格的参数,一般我们传入函数时,需要这个结构体的地址,同时我们需要知道结构体的大小,一般外国友人对这种大小的形式参数名称会使用buffer,所以很多地方会翻译成缓冲区,这是很容易让人造成误解的,所以日常看到缓冲区,请直接替换为存储空间
    //返回值:成功则返回0,失败返回非0,错误码GetLastError()。
    int result = connect(_clinenId, (const struct sockaddr *)&socketAddr, sizeof(socketAddr));
    if (result != 0) {
        NSLog(@"连接socket 失败");
        return result;
    }
    NSLog(@"连接成功");
    return result;
}

- (void)messageSend:(NSString *)message{
    //客户端write操作
    //ssize_t send(int sockfd, const void *buff, size_t nbytes, int flags);
    /*
     sockfd:一个用于标识已连接套接口的描述字。
     buff:包含待发送数据的缓冲区。
     nbytes:缓冲区中数据的长度。
     flags:调用执行方式。
     返回值:如果成功,则返回发送的字节数,失败则返回SOCKET_ERROR,一个中文UTF8 编码对应 3 个字节。所以上面发送了3*4字节
     */
    if (message.length == 0) {
        return;
    }
    if (_socketReady == NO) {
        NSLog(@"socket连接未建立");
        return;
    }
    const char *msg = message.UTF8String;
    //send() 等同于 write() 多提供了一个参数来控制读写操作
    ssize_t sendLen = send(self.clinenId, msg, strlen(msg), 0);
    NSLog(@"发送了:%ld字节",sendLen);
}

- (void)messgeRecieve{
    while(1){
        //客户端read操作
        //ssize_t recv(int sockfd, void *buff, size_t nbytes, int flags);
        /*
         sockfd:一个用于标识已连接套接口的描述字。
         buff:包含待发送数据的缓冲区。//待发送数据的地址
         nbytes:缓冲区中数据的长度。//待发送数据的大小
         flags:调用执行方式。
         返回值:如果成功,则返回读入的字节数,失败则返回SOCKET_ERROR。
         */
        uint8_t buffer[1024];
        //recv() 等同于 read() 多提供了一个参数来控制读写操作
        ssize_t recvLen = recv(self.clinenId, buffer, sizeof(buffer), 0);
        NSLog(@"接收到了:%ld字节",recvLen);
        if (recvLen==0) {
            NSLog(@"此次传输长度为0 如果下次还为0 请检查连接");
            continue;
        }
        // 接收到的数据转换
        NSData *recvData  = [NSData dataWithBytes:buffer length:recvLen];
        NSString *recvStr = [[NSString alloc] initWithData:recvData encoding:NSUTF8StringEncoding];
        NSLog(@"%@",recvStr);
    }
}

@end

服务端Socket

服务端Socket大体与客户端Socket相同,只不过多了一些操作,如bind,listen,accept,以及close
首先,我们也需要创建一个服务端的Socket,这点与客户端没有区别,我们得到了一个服务端Socket的文件描述符。
其次,我们通过bind操作,将我们想要监听的服务器IP以及端口地址配置进入我们的Socket。
然后,我们通过listen操作,开启对该socket的监听,这时我们可以设置允许的最大连接数。
接着,我们可以通过accept,来对访问服务器的客户端Socket作出响应,具体就是接收到Socket,然后开始read客户端发送的消息。
接下来就和客户端开始愉快的read,write了。
直到调用了close。

#import "SocketServer.h"
#import <sys/socket.h>
#import <netinet/in.h>
#import <arpa/inet.h>

#define SocketPort htons(8040)
#define SocketIP   inet_addr("127.0.0.1")
#define kMaxConnectCount   5

@interface SocketServer ()
@property (nonatomic, assign) int serverId;
@property (nonatomic, assign) int client_socket;
@end

@implementation SocketServer

- (void)buildServerSocket{
    self.serverId = socket(AF_INET, SOCK_STREAM, 0);
    if (self.serverId == -1) {
        NSLog(@"创建socket 失败");
        return;
    }
    NSLog(@"创建socket 成功");
}

- (void)bindSocket{
    //这里是对服务端的设置
    //这里设置你要绑定的是服务器的哪个IP
    struct sockaddr_in socketAddr;
    socketAddr.sin_family   = AF_INET;
    socketAddr.sin_port     = SocketPort;
    struct in_addr  socketIn_addr;
    socketIn_addr.s_addr    = SocketIP;
    socketAddr.sin_addr     = socketIn_addr;
    bzero(&(socketAddr.sin_zero), 8);
    
    // 2: 绑定socket
    int bind_result = bind(self.serverId, (const struct sockaddr *)&socketAddr, sizeof(socketAddr));
    if (bind_result == -1) {
        NSLog(@"绑定socket 失败");
        return;
    }

    NSLog(@"绑定socket成功");
}

- (void)listenSocket{
    //这里也是监听服务端的
    // 3: 监听socket 监听时设置允许的最大连接数
    int listen_result = listen(self.serverId, kMaxConnectCount);
    if (listen_result == -1) {
        NSLog(@"监听失败");
        return;
    }
    NSLog(@"监听成功");
}

- (void)acceptSocket{
    //获取客户端的连接Socket
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        struct sockaddr_in client_address;
        socklen_t address_len;
        // accept函数
        int client_socket = accept(self.serverId, (struct sockaddr *)&client_address, &address_len);
        self.client_socket = client_socket;
        
        if (client_socket == -1) {
            NSLog(@"接受 %u 客户端错误",address_len);
        }else{
            NSString *acceptInfo = [NSString stringWithFormat:@"客户端 in,socket:%d",client_socket];
            NSLog(@"%@",acceptInfo);
           //开始接受消息
            [self receiveMsgWithClietnSocket:client_socket];
        }
    });
}

- (void)receiveMsgWithClietnSocket:(int)clientSocket{
    while (1) {
        // 5: 接受客户端传来的数据
        char buf[1024] = {0};
        long iReturn = recv(clientSocket, buf, 1024, 0);
        if (iReturn>0) {
            NSLog(@"客户端来消息了");
            // 接收到的数据转换
            NSData *recvData  = [NSData dataWithBytes:buf length:iReturn];
            NSString *recvStr = [[NSString alloc] initWithData:recvData encoding:NSUTF8StringEncoding];
            NSLog(@"%@",recvStr);
            
        }else if (iReturn == -1){
            NSLog(@"读取消息失败");
            break;
        }else if (iReturn == 0){
            NSLog(@"客户端走了");
            
            close(clientSocket);
            
            break;
        }
    }
}

- (void)send{
    const char *msg = @"给客户端发消息".UTF8String;
    ssize_t sendLen = send(self.client_socket, msg, strlen(msg), 0);
    NSLog(@"发送了:%ld字节",sendLen);
}

- (void)close{
    int close_result = close(self.client_socket);
       
       if (close_result == -1) {
           NSLog(@"socket 关闭失败");
           return;
       }else{
           NSLog(@"socket 关闭成功");
       }
}

@end

sockaddr_in , sockaddr , in_addr的区别和联系

https://blog.csdn.net/maopig/article/details/17193021
在上面的代码中,我们发现了上面几个结构体,有点蒙,他们是干什么的呢?
sockaddr是一个通用的结构体,我们在使用bind,accept等操作传入参数时,都是使用这个结构体,这方便了程序的扩展,使bind和accept适用多种网络的结构体
sockaddr_in是针对internet网络类型的结构体,主要就是ipv4,ipv6这些。其中的sin_zero是为了使其保持与sockaddr同样的大小,从而实现向sockaddr转化。在上面的代码中,我们在调用bind操作与accept操作时,在传参时做了这样的转化。同时sin_zero可作为保留字段,为以后的扩展做准备。
in_addr是保存互联网类型地址的结构体,其中的in_addr_t用来保存internet address

sockaddr_un

前面提到除了socketaddr_in还有其他形式的sockaddr,那就是sockaddr_un.
sockaddr_un进程间通信的一种方式是使用UNIX套接字,人们在使用这种方式时往往用的不是网络套接字,而是一种称为本地套接字的方式。这样做可以避免为黑客留下后门。
推荐阅读:Linux网络编程之sockaddr与sockaddr_in,sockaddr_un结构体详细讲解
https://blog.csdn.net/gladyoucame/article/details/8768731

#ifndef _SA_FAMILY_T
#define _SA_FAMILY_T
#include <machine/types.h> /* __uint8_t */
typedef __uint8_t               sa_family_t;
#endif  /* _SA_FAMILY_T */

/*
 * [XSI] Structure used by kernel to store most addresses.
 */
struct sockaddr {
    __uint8_t       sa_len;         /* total length */
    sa_family_t     sa_family;      /* [XSI] address family */
    char            sa_data[14];    /* [XSI] addr value (actually larger) */
};
/*
 * Socket address, internet style.
 */
struct sockaddr_in {
    __uint8_t       sin_len;
    sa_family_t     sin_family;
    in_port_t       sin_port;
    struct  in_addr sin_addr;
    char            sin_zero[8];
};
/*
 * Internet address (a structure for historical reasons)
 */
#ifndef _IN_ADDR_T
#define _IN_ADDR_T
#include <machine/types.h> /* __uint32_t */
typedef __uint32_t      in_addr_t;      /* base type for internet address */
#endif /* _IN_ADDR_T */

struct in_addr {
    in_addr_t s_addr;
};

Unix系统一切皆文件

http://ju.outofmemory.cn/entry/365735

在UNIX系统中,各种输入输出资源都使用了一个统一的抽象“文件”,每个文件都有一个文件描述符,它是对文件的一个引用,文件是可以读取和写入的普通字节的集合。如果你持有一个文件引用(也就是文件描述符),就可以使用相同的一组API进行IO操作,无论设备的类型和底层硬件是什么。UNIX为其提供统一的操作接口,用户可以对这些字节流进行读(read)、写(write)、随机访问(lseek)和关闭(close)操作。并且用户只需与抽象的标准输入和标准输出相交互,不用在意底层的细节。

UNIX操作系统的设计、用户界面、文化和演变都是建立在它的一套统一的想法和概念上。其中最重要的一点可能是“一切皆文件”,而这个概念被认为是UNIX的灵魂之一。
这一关键设计原则提供了一个统一的范式,用于访问各种输入输出资源:文档、目录、磁盘驱动器、CD-ROM、调制解调器、键盘、打印机、显示器、终端,甚至是一些进程间通信和网络通信。所有这些资源拥有一个通用的抽象,UNIX之父将其称为“文件”。因为每个“文件”都通过相同的API暴露出来,所以你可以使用同一组基本命令来读取和写入磁盘、键盘、文档或网络设备。

这个基本概念实际上有双重含义:

在UNIX中,一切都是字节流
在UNIX中,文件系统是统一的命名空间

在UNIX中,一切都是字节流

那么UNIX中的文件是由什么组成的?文件只不过是可以读取和写入的普通字节的集合。如果你持有一个文件引用(也就是文件描述符),就可以使用相同的一组API进行IO操作,无论设备的类型和底层硬件是什么。

UNIX是第一个对所有IO操作进行统一抽象并提供一小组操作原语的操作系统。

从程序员和用户的角度来看,UNIX通过字节流的形式暴露了如下的资源:

存储在磁盘上的文档、目录、链接、大容量存储设备(如磁盘驱动器、CD-ROM、磁带、USB)、进程间通信(例如管道、共享内存、UNIX套接字)、网络连接、交互式终端、其他设备(如打印机、图形卡)。用户可以对这些字节流进行读(read)、写(write)、随机访问(lseek)和关闭(close)操作。

统一的API对于UNIX程序来说非常重要:你可以尽情地开发一个处理文件的程序,却不需要知道文件内容究竟是来自哪里,它们可能保存在本地磁盘或网络某处的远程驱动器上,或者是通过网络流入、由用户输入,甚至是由另一个程序在内存中生成。这大大降低了程序的复杂性,并简化了开发人员的学习曲线。这一基本特性让程序开发变得轻而易举(你只需与两个特殊文件打交道:标准输入和标准输出)。

不过需要注意的是,虽然所有文件都提供了统一的API,但某些特定类型的设备可能不支持某些操作。例如,你无法对鼠标设备进行lseek操作,也无法在CD-ROM设备上进行write操作(假设你的CD是只读的)。

在UNIX中,文件系统是统一的命名空间

在UNIX中,文件不只是具有统一API的字节流,还可以通过统一的方式来引用它们:文件系统就是统一的命名空间。

文件描述符

https://baike.baidu.com/item/%E6%96%87%E4%BB%B6%E6%8F%8F%E8%BF%B0%E7%AC%A6

内核(kernel)利用文件描述符(file descriptor)来访问文件。文件描述符是非负整数。打开现存文件或新建文件时,内核会返回一个文件描述符。读写文件也需要使用文件描述符来指定待读写的文件
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIXLinux这样的操作系统。

文件描述符的有效范围是 0 到 OPEN_MAX。一般来说,每个进程最多可以打开 64 个文件(0 — 63)。对于 FreeBSD 5.2.1、Mac OS X 10.3 和 Solaris 9 来说,每个进程最多可以打开文件的多少取决于系统内存的大小,int 的大小,以及系统管理员设定的限制。Linux 2.4.22 强制规定最多不能超过 1,048,576 。

文件描述符是由无符号整数表示的句柄,进程使用它来标识打开的文件。文件描述符与包括相关信息(如文件的打开模式、文件的位置类型、文件的初始类型等)的文件对象相关联,这些信息被称作文件的上下文。

对于游戏,直播,即时通讯这种有状态的服务,我们每个连接都需要建立socket,每个socket都需要打开一个文件,每个文件都有一个文件描述符。而进程分配的描述符是有限的,操作系统的进程也是有限的。所以一个服务器能提供的socket链接也是有限的。因此,游戏,直播,即时通讯这种有状态的服务是十分耗费服务器资源的。

socket的阻塞与非阻塞

https://www.cnblogs.com/qionglouyuyu/p/4823611.html

阻塞:
调用我(函数),我(函数)没有接收完数据或者没有得到结果之前,我不会返回。

非阻塞:
调用我(函数),我(函数)立即返回通知调用者

以最常用的send和recv两个函数为例
比如你调用send函数发送一定的Byte,在系统内部send做的工作其实只是把数据传输(Copy)到TCP/IP协议栈的输出缓冲区,它执行成功并不代表数据已经成功的发送出去了,如果TCP/IP协议栈没有足够的可用缓冲区来保存你Copy过来的数据的话...这时候就体现出阻塞和非阻塞的不同之处了:对于阻塞模式的socket send函数将不返回直到系统缓冲区有足够的空间把你要发送的数据Copy过去以后才返回,而对于非阻塞的socket来说send会立即返回WSAEWOULDDBLOCK告诉调用者说:"发送操作被阻塞了!!!你想办法处理吧..."
对于recv函数,同样道理,对于阻塞模式的socket来说如果TCP/IP协议栈的接收缓冲区没有通知一个结果给它它就一直不返回:耗费着系统资源....对于非阻塞模式的socket该函数会马上返回,然后告诉你:WSAEWOULDDBLOCK---"现在没有数据,回头再来看看"


非阻塞I/O

阻塞I/O

那对于阻塞和非阻塞我们应该怎么选择呢?阻塞调用比较方便,我们调用之后,等待结果就好,不用操心,但是缺点就是会阻塞当前线程,对于多个socket连接,我们或许需要开启多个线程,但是线程的个数是有限的,这样就大大限制了我们的发挥能力。非阻塞调用相对麻烦,你需要自己去控制轮询操作,根据服务器的返回去做各种各样的判断。但是好处是给了你更多的灵活性,你可以在一个线程上操作多个连接,同时处理他们。不过不停地轮询将大幅度推高CPU 占用率,有一种方法是用事件监听机制,在iOS上就可以考虑使用runloop来帮助我们

网络IO模型

参考文章:
网络IO模型
同步与异步IO区别
下文是对上述链接的一个摘抄,文章写得很不错。
这里先总结一下,常见网络IO有五种,阻塞IO,非阻塞IO,IO多路复用,信号驱动IO和异步IO。在IO操作中主要有两个阶段,一个阶段是请求数据,从服务器请求数据到系统内核,一个阶段是拷贝数据,从内核读取数据到应用缓存。在阻塞IO中,IO在两个阶段都处于阻塞状态。为了解决这个问题,非阻塞IO通过在请求数据时立即返回一个状态码的方式,实现了非阻塞的等待数据,但是你需要不停地轮询查询状态,大量的轮询让你CPU会受不了。为了减少轮询的次数。出现了IO多路复用,多路复用允许你通过一个标志位来标记某个连接的状态,fd_set 类型可以简单的理解为按 bit 位标记句柄的队列,例如要在某 fd_set 中标记一个值为16的句柄,则该fd_set的第16个bit位被标记为1。具体的置位、验证可使用 FD_SET、FD_ISSET等宏实现。当我们轮询调用select方法时,该方法会一次性检查fd_set中多个连接的状态并作出相应处理。但是在select阶段我们是阻塞的,如果我们某一个操作过于耗时,后续的处理都会延后。为了解决这个问题,出现了signal模式,他让你在整个等待数据阶段都无需轮询和阻塞,通过sigaction系统调用安装一个信号处理函数,系统调用立即返回,当数据报准备好读取时,内核产生一个信号,随后就可以在信号处理函数中调用recvfrom读取数据报,将数据从内核空间复制到用户空间。但是上述的IO,在拷贝数据时都是阻塞的。因此就出现了异步IO,调用aio_系列函数,给内核传递描述符、缓冲区指针、缓冲区大小和文件偏移,该系统调用立即返回,当整个IO操作完成,即数据报到达,并且从内核缓冲区被复制到用户缓冲区后,才产生一个信号。

IO操作一般是性能的瓶颈,冗余的复制操作让计算的性能下降,所以去除多余的复制操作,往往是我们性能优化时需要考虑的,就像spark通过去掉hadoop中间层的数据复制来提升性能

对于数据的拷贝,找到了这样一篇文章
https://www.cnblogs.com/sunsky303/p/8962628.html
这里主要分为三种状态,磁盘,内核缓存区,用户态(应用缓存区)

五种模型
* blocking IO
* nonblocking IO
* IO multiplexing
* signal driven IO
* asynchronous IO

对于一个network IO (这里我们以read举例),它会涉及到两个系统对象,一个是调用这个IO的process (or thread),另一个就是系统内核(kernel)。当一个read操作发生时,它会经历两个阶段:
1)等待数据准备 (Waiting for the data to be ready)
2)将数据从内核拷贝到进程中(Copying the data from the kernel to the process)

blocking IO

blocking IO的特点就是在IO执行的两个阶段(等待数据和拷贝数据两个阶段)都被block了


图1 阻塞IO

几乎所有的程序员第一次接触到的网络编程都是从listen()、send()、recv() 等接口开始的,这些接口都是阻塞型的。我们注意到,大部分的socket接口都是阻塞型的。所谓阻塞型接口是指系统调用(一般是IO接口)不返回调用结果并让当前线程一直阻塞,只有当该系统调用获得结果或者超时出错时才返回。实际上,除非特别指定,几乎所有的IO接口 ( 包括socket接口 ) 都是阻塞型的。这给网络编程带来了一个很大的问题,如在调用send()的同时,线程将被阻塞,在此期间,线程将无法执行任何运算或响应任何的网络请求。


图2 简单的一问一答的服务器/客户机模型

一个简单的改进方案是在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连接。具体使用多进程还是多线程,并没有一个特定的模式。传统意义上,进程的开销要远远大于线程,所以如果需要同时为较多的客户机提供服务,则不推荐使用多进程;如果单个服务执行体需要消耗较多的CPU资源,譬如需要进行大规模或长时间的数据运算或文件访问,则进程较为安全。通常,使用pthread_create ()创建新线程,fork()创建新进程。
我们假设对上述的服务器 / 客户机模型,提出更高的要求,即让服务器同时为多个客户机提供一问一答的服务。于是有了如下的模型。


图3 多线程的服务器模型

在上述的线程 / 时间图例中,主线程持续等待客户端的连接请求,如果有连接,则创建新线程,并在新线程中提供为前例同样的问答服务。
很多初学者可能不明白为何一个socket可以accept多次。实际上socket的设计者可能特意为多客户机的情况留下了伏笔,让accept()能够返回一个新的socket。下面是 accept 接口的原型:
int accept(int s, struct sockaddr *addr, socklen_t *addrlen);
输入参数s是从socket(),bind()和listen()中沿用下来的socket句柄值。执行完bind()和listen()后,操作系统已经开始在指定的端口处监听所有的连接请求,如果有请求,则将该连接请求加入请求队列。调用accept()接口正是从 socket s 的请求队列抽取第一个连接信息,创建一个与s同类的新的socket返回句柄。新的socket句柄即是后续read()和recv()的输入参数。如果请求队列当前没有请求,则accept() 将进入阻塞状态直到有请求进入队列。
上述多线程的服务器模型似乎完美的解决了为多个客户机提供问答服务的要求,但其实并不尽然。如果要同时响应成百上千路的连接请求,则无论多线程还是多进程都会严重占据系统资源,降低系统对外界响应效率,而线程与进程本身也更容易进入假死状态。
很多程序员可能会考虑使用“线程池”或“连接池”。“线程池”旨在减少创建和销毁线程的频率,其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。“连接池”维持连接的缓存池,尽量重用已有的连接、减少创建和关闭连接的频率。这两种技术都可以很好的降低系统开销,都被广泛应用很多大型系统,如websphere、tomcat和各种数据库等。但是,“线程池”和“连接池”技术也只是在一定程度上缓解了频繁调用IO接口带来的资源占用。而且,所谓“池”始终有其上限,当请求大大超过上限时,“池”构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用“池”必须考虑其面临的响应规模,并根据响应规模调整“池”的大小。
对应上例中的所面临的可能同时出现的上千甚至上万次的客户端请求,“线程池”或“连接池”或许可以缓解部分压力,但是不能解决所有问题。总之,多线程模型可以方便高效的解决小规模的服务请求,但面对大规模的服务请求,多线程模型也会遇到瓶颈,可以用非阻塞接口来尝试解决这个问题。

非阻塞IO(non-blocking IO)

Linux下,可以通过设置socket使其变为non-blocking。


图4 非阻塞IO

在非阻塞式IO中,用户进程其实是需要不断的主动询问kernel数据准备好了没有。
非阻塞的接口相比于阻塞型接口的显著差异在于,在被调用之后立即返回。使用如下的函数可以将某句柄fd设为非阻塞状态。
fcntl( fd, F_SETFL, O_NONBLOCK );
下面将给出只用一个线程,但能够同时从多个连接中检测数据是否送达,并且接受数据的模型。


图5 使用非阻塞的接收数据模型

在非阻塞状态下,recv() 接口在被调用后立即返回,返回值代表了不同的含义。如在本例中,
* recv() 返回值大于 0,表示接受数据完毕,返回值即是接受到的字节数;

* recv() 返回 0,表示连接已经正常断开;
* recv() 返回 -1,且 errno 等于 EAGAIN,表示 recv 操作还没执行完成;
* recv() 返回 -1,且 errno 不等于 EAGAIN,表示 recv 操作遇到系统错误 errno。
可以看到服务器线程可以通过循环调用recv()接口,可以在单个线程内实现对所有连接的数据接收工作。但是上述模型绝不被推荐。因为,循环调用recv()将大幅度推高CPU 占用率;此外,在这个方案中recv()更多的是起到检测“操作是否完成”的作用,实际操作系统提供了更为高效的检测“操作是否完成“作用的接口,例如select()多路复用模式,可以一次检测多个连接是否活跃。

多路复用IO(IO multiplexing)

IO multiplexing这个词可能有点陌生,但是如果我说select/epoll,大概就都能明白了。有些地方也称这种IO方式为事件驱动IO(event driven IO)。我们都知道,select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。它的流程如图:


图6 多路复用IO

大部分Unix/Linux都支持select函数,该函数用于探测多个文件句柄的状态变化。下面给出select接口的原型:
FD_ZERO(int fd, fd_set* fds)
FD_SET(int fd, fd_set* fds)
FD_ISSET(int fd, fd_set* fds)
FD_CLR(int fd, fd_set* fds)
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,
struct timeval *timeout)
这里,fd_set 类型可以简单的理解为按 bit 位标记句柄的队列,例如要在某 fd_set 中标记一个值为16的句柄,则该fd_set的第16个bit位被标记为1。具体的置位、验证可使用 FD_SET、FD_ISSET等宏实现。在select()函数中,readfds、writefds和exceptfds同时作为输入参数和输出参数。如果输入的readfds标记了16号句柄,则select()将检测16号句柄是否可读。在select()返回后,可以通过检查readfds有否标记16号句柄,来判断该“可读”事件是否发生。另外,用户可以设置timeout时间。
下面将重新模拟上例中从多个客户端接收数据的模型。


图7 使用select()的接收数据模型

述模型只是描述了使用select()接口同时从多个客户端接收数据的过程;由于select()接口可以同时对多个句柄进行读状态、写状态和错误状态的探测,所以可以很容易构建为多个客户端提供独立问答服务的服务器系统。如下图。
图8 使用select()接口的基于事件驱动的服务器模型

这里需要指出的是,客户端的一个 connect() 操作,将在服务器端激发一个“可读事件”,所以 select() 也能探测来自客户端的 connect() 行为。
上述模型中,最关键的地方是如何动态维护select()的三个参数readfds、writefds和exceptfds。作为输入参数,readfds应该标记所有的需要探测的“可读事件”的句柄,其中永远包括那个探测 connect() 的那个“母”句柄;同时,writefds 和 exceptfds 应该标记所有需要探测的“可写事件”和“错误事件”的句柄 ( 使用 FD_SET() 标记 )。
作为输出参数,readfds、writefds和exceptfds中的保存了 select() 捕捉到的所有事件的句柄值。程序员需要检查的所有的标记位 ( 使用FD_ISSET()检查 ),以确定到底哪些句柄发生了事件。
上述模型主要模拟的是“一问一答”的服务流程,所以如果select()发现某句柄捕捉到了“可读事件”,服务器程序应及时做recv()操作,并根据接收到的数据准备好待发送数据,并将对应的句柄值加入writefds,准备下一次的“可写事件”的select()探测。同样,如果select()发现某句柄捕捉到“可写事件”,则程序应及时做send()操作,并准备好下一次的“可读事件”探测准备。下图描述的是上述模型中的一个执行周期。


图9 多路复用模型的一个执行周期

这种模型的特征在于每一个执行周期都会探测一次或一组事件,一个特定的事件会触发某个特定的响应。我们可以将这种模型归类为“事件驱动模型”。
相比其他模型,使用select() 的事件驱动模型只用单线程(进程)执行,占用资源少,不消耗太多 CPU,同时能够为多客户端提供服务。如果试图建立一个简单的事件驱动的服务器程序,这个模型有一定的参考价值。
但这个模型依旧有着很多问题。首先select()接口并不是实现“事件驱动”的最好选择。因为当需要探测的句柄值较大时,select()接口本身需要消耗大量时间去轮询各个句柄。很多操作系统提供了更为高效的接口,如linux提供了epoll,BSD提供了kqueue,Solaris提供了/dev/poll,…。如果需要实现更高效的服务器程序,类似epoll这样的接口更被推荐。遗憾的是不同的操作系统特供的epoll接口有很大差异,所以使用类似于epoll的接口实现具有较好跨平台能力的服务器会比较困难。
其次,该模型将事件探测和事件响应夹杂在一起,一旦事件响应的执行体庞大,则对整个模型是灾难性的。如下例,庞大的执行体1的将直接导致响应事件2的执行体迟迟得不到执行,并在很大程度上降低了事件探测的及时性。


图10 庞大的执行体对使用select()的事件驱动模型的影响

幸运的是,有很多高效的事件驱动库可以屏蔽上述的困难,常见的事件驱动库有libevent库,还有作为libevent替代者的libev库。这些库会根据操作系统的特点选择最合适的事件探测接口,并且加入了信号(signal) 等技术以支持异步响应,这使得这些库成为构建事件驱动模型的不二选择。下章将介绍如何使用libev库替换select或epoll接口,实现高效稳定的服务器模型。
实际上,Linux内核从2.6开始,也引入了支持异步响应的IO操作,如aio_read, aio_write,这就是异步IO

异步IO(Asynchronous I/O)

图11 异步IO

用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。
用异步IO实现的服务器这里就不举例了,以后有时间另开文章来讲述。异步IO是真正非阻塞的,它不会对请求进程产生任何的阻塞,因此对高并发的网络服务器实现至关重要。
到目前为止,已经将四个IO模型都介绍完了。现在回过头来回答最初的那几个问题:blocking和non-blocking的区别在哪,synchronous IO和asynchronous IO的区别在哪。
先回答最简单的这个:blocking与non-blocking。前面的介绍中其实已经很明确的说明了这两者的区别。调用blocking IO会一直block住对应的进程直到操作完成,而non-blocking IO在kernel还在准备数据的情况下会立刻返回。
在说明synchronous IO和asynchronous IO的区别之前,需要先给出两者的定义。Stevens给出的定义(其实是POSIX的定义)是这样子的:
* A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
* An asynchronous I/O operation does not cause the requesting process to be blocked;
两者的区别就在于synchronous IO做”IO operation”的时候会将process阻塞。按照这个定义,之前所述的blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO。有人可能会说,non-blocking IO并没有被block啊。这里有个非常“狡猾”的地方,定义中所指的”IO operation”是指真实的IO操作,就是例子中的recvfrom这个系统调用。non-blocking IO在执行recvfrom这个系统调用的时候,如果kernel的数据没有准备好,这时候不会block进程。但是当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内进程是被block的。而asynchronous IO则不一样,当进程发起IO操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。

还有一种不常用的signal driven IO,即信号驱动IO。总的来说,UNP中总结的IO模型有5种之多:阻塞IO,非阻塞IO,IO复用,信号驱动IO,异步IO。前四种都属于同步IO。阻塞IO不必说了。非阻塞IO ,IO请求时加上O_NONBLOCK一类的标志位,立刻返回,IO没有就绪会返回错误,需要请求进程主动轮询不断发IO请求直到返回正确。IO复用同非阻塞IO本质一样,不过利用了新的select系统调用,由内核来负责本来是请求进程该做的轮询操作。看似比非阻塞IO还多了一个系统调用开销,不过因为可以支持多路IO,才算提高了效率。信号驱动IO,调用sigaltion系统调用,当内核中IO数据就绪时以SIGIO信号通知请求进程,请求进程再把数据从内核读入到用户空间,这一步是阻塞的。
异步IO,如定义所说,不会因为IO操作阻塞,IO操作全部完成才通知请求进程。
各个IO Model的比较如图所示:


图12 各种IO模型的比较

经过上面的介绍,会发现non-blocking IO和asynchronous IO的区别还是很明显的。在non-blocking IO中,虽然进程大部分时间都不会被block,但是它仍然要求进程去主动的check,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom来将数据拷贝到用户内存。而asynchronous IO则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。

GCD编程

https://www.jianshu.com/p/886ec5cc90bb
https://developer.apple.com/library/archive/documentation/General/Conceptual/ConcurrencyProgrammingGuide/GCDWorkQueues/GCDWorkQueues.html#//apple_ref/doc/uid/TP40008091-CH103-SW13

GCDAsyncSocket && GCDAsyncUdpSocket

https://github.com/robbiehanson/CocoaAsyncSocket
GCDAsyncSocket is a TCP/IP socket networking library built atop Grand Central Dispatch.
GCDAsyncUdpSocket is a UDP/IP socket networking library built atop Grand Central Dispatch.
GCDAsyncSocket && GCDAsyncUdpSocket是对TCP和UDP的封装,使你远离繁琐的socket编程,他为你提供了代理回调让你的socket编程更简单。不过我们对其内部做了什么以及进行了怎样的优化有一个详细的了解,对我们后续的工作会有很大的帮助

再开始之前我们应该先阅读一下GCDAsyncSocket作者写的wiki
https://github.com/robbiehanson/CocoaAsyncSocket/wiki/Intro
https://github.com/robbiehanson/CocoaAsyncSocket/wiki/GeneralDocumentation
https://github.com/robbiehanson/CocoaAsyncSocket/wiki/Intro_GCDAsyncSocket

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,589评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,615评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,933评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,976评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,999评论 6 393
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,775评论 1 307
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,474评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,359评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,854评论 1 317
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,007评论 3 338
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,146评论 1 351
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,826评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,484评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,029评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,153评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,420评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,107评论 2 356

推荐阅读更多精彩内容

  • 大纲 一.Socket简介 二.BSD Socket编程准备 1.地址 2.端口 3.网络字节序 4.半相关与全相...
    VD2012阅读 2,348评论 0 5
  • 一、网络中进程之间如何通信 首要解决的问题是如何唯一标识一个进程,否则通信无从谈起!在本地可以通过进程PID来唯一...
    向阳的向日葵花阅读 2,040评论 0 4
  • Socket基础概念 网络中进程之间如何通信? 网络中进程之间如何通信?首要解决的问题是如何唯一标识一个进程,否则...
    DiamondsAndRust阅读 4,753评论 2 54
  • 看见你的笑 我才知道 这世界竟如此美好
    来日長阅读 156评论 1 4
  • 人的一生中,总要找到几首歌曲一路陪伴,今天回京一直在听这首歌,创业路上的点滴映入眼帘,不忘初心,方得始终。 《在路...
    瑜伽赵老师阅读 169评论 0 5