一、Socket
我们常说的套接字最早是加州大学Berkeley在60年代在Unix上开发的用C语言写成的应用程序开发库(BSD Socket),主要用于实现进程间通讯,在计算机网络通讯方面被广泛使用。现在在Linux系统里大多是指INET socket,是BSD Socket里IPv4网络协议的接口, 其定义是源IP地址和目的IP地址以及源端口号和目的端口号的组合,实质上就是一个编程的接口,方便自上而下的调用相关的函数。
在Linux系统里,定义了三种套接字类型:分别是(i)使用TCP协议的流套接字(SOCK_STREAM),(ii)使用UDP协议的数据报套接字(SOCK_DGRAM),(iii)可以使用IP协议、ICMP协议的 (SOCK_RAW)。
第一种TCP协议提供面向连接、可靠的数据传输服务,因此能够保证数据实现无差错、无重复发送,并按顺序接收,因此用于发送命令。
而UDP协议则不提供链接,因此也就不能保证数据可以按照顺序,无丢失的到达接收方。一般情况下,用于发送不那么重要的信息,如音视频。
原始套接字一般用的少,它可以直接访问较低层的协议,如IP协议,ICMP协议,IGMP协议等,控制网络层和传输层的应用。它一般用于网络监听(注:OSI网络模型与相关的协议是物理层、数据链路层【PPP,WI- FI,以太网,令牌环,FDDI等】、网络层【IP】、传输层【TCP,UDP】、会话层、表示层和应用层【HTTP,TELNET,DNS,EMAIL等】)。
Linux系统下有一整套的流程来操作,其他开发平台(IDE)也是,只不过做了封装。按照套接字的定义,就是对发送和接收地址、端口号进行设置。
i)创建套接字 int socket(int family, int type, intprotocol)
它有三个参数,第一个参数family是代表一个协议族,如代表Internet的AF_INET,代表可以操作链路层数据的PF_PACKET等。第二个参数是套接字类型,常见类型是SOCK_STREAM,SOCK_DGRAM, SOCK_RAW, SOCK_PACKET【这个是Linux早期使用的,后来创建了PF_PACKET来取代它】等;第三个参数是具体的协议,对于标准套接字来说,其值是0,对于原始套接字来说就是具体的协议值。
若创建成功,就返回一个非0的正数,作为这整个套接字流程的唯一标识符,在LINUX系统里称之为文件描述符,也就是下文提及的sockfd。若失败,函数返回一个负数(第一次创建的话,就是-1)
ii)绑定函数: int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen)
它的任务是把地址,端口号这些都在struct sockaddr类型的参数myaddr里配置好待用;需要注意的是参数是不能传递结构体,只有采用引用的方式,而不是值传递。
iii)监听函数:int listen(int sockfd, int backlog)
一般情况下,它起作用只是在有多个客户端与服务器端已经建立了连接(已经标记是ESTABLISHED状态),但服务器端不能及时处理,就需要把这些客户端连接放到一个等待缓冲队列中去(也称全连接队列),队列的长度由backlog指定。它是服务器端特有的函数,客户端是不需要的。需要知道的是,全连接队列长度backlog的(默认值是5)最大值是128,保存在内核空间net.core.somaxconn,一般情况下,需要将用户设置的(listen函数里的backlog)传递给内核里对应的变量。
iv)请求接收函数: int accept(int sockfd, structsockaddr *client_addr, socklen_t *len)
它同样也只用于服务器模式一端,任务是等待客户端发起连接,一旦连接成功,就从全连接队列(即标记是ESTABLISHED,LINUX v2.2之前还包括半连接状态,即SYN_RCVD。另外这个队列长度由上文listen函数里的backlog确定)中取出一个全连接状态的连接请求块,返回它所对应的连接sock。若是队列为空,则要看它是否阻塞。一般情况下,系统调用(I/O)都是阻塞的。无论是否阻塞,都设置一个超时时间比较稳妥。需要注意的是,它保存的是客户端的地址与端口信息,因为这个连接是客户端发起的。
v)客户端请求连接函数: int connect(int sock_fd, struct sockaddr *serv_addr,int addrlen)
它是客户端特有的函数,任务是是向服务器端发送连接请求,这也是从客户端发起TCP三次握手请求的开始(即先向服务器发送request消息,服务器端应答发送ACK表示收到,最后客户端再向服务器端发送一个确认信息)。它返回0时说明已经connect成功,返回值是-1时,表示connect失败。整个过程都是在内核完成的,connect()只是简单的起到通知内核的作用。
在服务器端和客户端建立连接之后是进行数据间的发送和接收,主要使用的接收函数是recv和read,发送函数是send和write。
(1)下面是个简单的服务器的代码(代码来自网络,做了少量的修改,但出处不知道,因为时间有点长。有知道的,烦请告知。下同)
。。。
#define IP "192.168.1.1"
#define PORT 8888 //端口号
#define SIZE 128 //定义的数组大小
int Creat_socket() //创建套接字和初始化以及监听函数
{
int listen_socket = socket(AF_INET, SOCK_STREAM, 0); //创建一个负责监听的套接字
if(listen_socket == -1)
{
perror("socket");
return -1;
}
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET; /* Internet地址族 */
addr.sin_port = htons(PORT); /* 端口号 */
addr.sin_addr.s_addr = inet_addr(IP); /* IP地址(IPv4) */
int ret = bind(listen_socket, (struct sockaddr *)&addr, sizeof(addr)); //连接
if(ret == -1)
{
perror("bind");
return -1;
}
ret = listen(listen_socket, 5); //监听
if(ret == -1)
{
perror("listen");
return -1;
}
return listen_socket;
}
int wait_client(int listen_socket)
{
struct sockaddr_in cliaddr;
int addrlen = sizeof(cliaddr);
printf("等待客户端连接。。。。\n");
int client_socket = accept(listen_socket, (struct sockaddr *)&cliaddr, &addrlen); //创建一个和客户端交流的套接字
if(client_socket == -1)
{
perror("accept");
return -1;
}
printf("成功接收到一个客户端:%s\n", inet_ntoa(cliaddr.sin_addr));
return client_socket;
}
void handle_client(int listen_socket, int client_socket) //信息处理函数,客户端接收数据
{
char buf[SIZE];
while(1)
{
int ret = read(client_socket, buf, SIZE-1);
if(ret == -1)
{
perror("read");
break;
}
if(ret == 0)
{
break;
}
buf[ret] = '\0';
int i;
for(i = 0; i < ret; i++)
{
// ...
}
printf("%s\n", buf);
write(client_socket, buf, ret);
// ...
}
close(client_socket);
}
int main()
{
int listen_socket = Creat_socket();
int client_socket = wait_client(listen_socket);
handle_client(listen_socket, client_socket);
close(listen_socket);
return 0;
}
(2)在实际上,更多的是需要多进程来处理多个客户端的,尤其是单个服务执行体需要消耗较多的 CPU 资源,譬如需要进行大规模或长时间的数据运算或文件访问,则进程较为安全。下面的例子里,父进程主要负责监听,子进程主要负责接收客户端,并做相关处理(如读写文件,做矩阵计算等)。为了防止防止父进程在接收函数处阻塞,导致子进程不能创建成功,故一开始就要把父进程的接收函数关闭掉。同样的,子进程在一创建就要把监听函数关闭,只做接收和处理数据的工作。另外,子进程在退出时会产生僵尸进程,所以我们一定要对子进程退出后进行释放内存等处理。
#include
#include
。。。
#define IP "192.168.1.1"
#define PORT 8888 //端口号
#define SIZE 128 //定义的数组大小
。。。
void handler(int sig)
{
while (waitpid(-1, NULL, WNOHANG) > 0)
{
printf ("成功处理一个子进程的退出\n");
}
}
int main()
{
int listen_socket = Creat_socket();
signal(SIGCHLD, handler); //处理子进程,防止僵尸进程的产生
while(1)
{
//多进程服务器,可以创建子进程来处理,父进程负责监听。
int client_socket = wait_client(listen_socket);
int pid = fork();
if(pid == -1)
{
perror("fork");
break;
}
if(pid > 0) // 父进程本身
{
close(client_socket);
continue;
}
if(pid == 0)
{
close(listen_socket);
handle_client(listen_socket, client_socket);
break;
}
}
close(listen_socket);
return 0;
}
从上面两个代码片段,可以看出,父进程在做了Creat_socket()后,就不做wait_client(),这个函数就是专门处理accept的。
(3)但如果客户端多的话,就会产生很多的子进程,都要复制父进程的一切信息,这样就导致了CPU资源的浪费,最终使得服务器的响应变慢,效率下降。因此转用多线程技术,用线程来做服务器。服务器端代码变动不大,只需要在主函数main()里改动就可以了。
#include
int main()
{
int listen_socket = Creat_socket();
while(1)
{
int client_socket = wait_client(listen_socket);
// 使用 多线程 技术
pthread_t id;
pthread_create(&id, NULL, handle_client, (void *)client_socket); //创建一个线程,来处理客户端。
pthread_detach(id); //把线程分离出去。
}
close(listen_socket);
return 0;
}
pthread_create是UNIX环境创建线程函数,Linux系统下的多线程遵循POSIX线程接口。函数的声明格式如下
int pthread_create(pthread_t*restrict tidp,const pthread_attr_t *restrict_attr, void*(*start_rtn)(void*), void *restrict arg);
返回值:
若成功则返回0,否则返回出错编号ENOMEM或EAGAIN。
返回成功时,由tidp指向的内存单元被设置为新创建线程的线程ID。attr参数用于制定各种不同的线程属性。新创建的线程从start_rtn函数的地址开始运行,该函数只有一个万能指针参数arg,如果需要向start_rtn函数传递的参数不止一个,那么需要把这些参数放到一个结构中,然后把这个结构的地址作为arg的参数传入。需要注意的是,arg只能用引用的形式,不可以用值传递的形式,而且要强制转换为 void * 类型。若无参数,直接用NULL。
简单来说,这些参数意思是
第一个参数为指向线程标识符的指针。
第二个参数用来设置线程属性。
第三个参数是线程运行函数的起始地址。
最后一个参数是运行函数的参数。
由 restrict 修饰的指针是最初唯一对指针所指向的对象进行存取的方法,仅当第二个指针基于第一个时,才能对对象进行存取。对对象的存取都限定于基于由 restrict 修饰的指针表达式中。 由 restrict 修饰的指针主要用于函数形参,或指向由 malloc() 分配的内存空间。restrict 数据类型不改变程序的语义。 编译器能通过作出 restrict 修饰的指针是存取对象的唯一方法的假设,更好地优化某些类型的例程。另外,在编译时注意加上-lpthread参数,以调用静态链接库。因为pthread并非Linux系统的默认库。
出错的情况比较复杂,其中一个是内存资源不够了。可以参考 CSDN博主 夜风~ 写的"linux——pthread_create()到底可以创建多少个线程?"(https://blog.csdn.net/u014470361/article/details/83352437 )里介绍的那样,根据实际情况修改线程栈大小。如通过 ulimit -s 查看并临时修改linux的默认栈空间大小,在/etc/rc.local 内 加入 ulimit -s 10240 则可以开机就设置栈空间大小为10M等。
最后注意的是,pthread_create(源代码参考nptl/pthread_create.c)是会造成内存泄漏的,因此detached不可少,如自己主动用pthread_detach ( pthread_self ( ) ) 释放资源或者在一个专门的线程里调用pthread_join来释放资源。
客户端相对于服务器来说就简单多了,客户端只需要创建和服务器相连接的套接字,然后对其初始化,然后再进行连接就可以了,连接上服务器就可以发送你想发送的数据了。
#define IP "192.168.1.1"
int main()
{
int client_socket = socket(AF_INET, SOCK_STREAM, 0); //创建和服务器连接套接字
if(client_socket == -1)
{
perror("socket");
return -1;
}
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET; /* Internet地址族 */
addr.sin_port = htons(PORT); /* 端口号 */
addr.sin_addr.s_addr = inet_addr(IP); /* IP地址(IPv4) */
//inet_aton("127.0.0.1", &(addr.sin_addr));
int addrlen = sizeof(addr);
int listen_socket = connect(client_socket, (struct sockaddr *)&addr, addrlen); //连接服务器
if(listen_socket == -1)
{
perror("connect");
return -1;
}
printf("成功连接到一个服务器\n");
char buf[SIZE] = {0};
while(1) //向服务器发送数据,并接收服务器数据
{
printf("Please Input:");
scanf("%s", buf);
write(client_socket, buf, strlen(buf));
int ret = read(client_socket, buf, strlen(buf));
printf("buf = %s", buf);
printf("\n");
}
close(listen_socket);
return 0;
}
(4)高并发
受前面所讲的backlog大小的影响(默认最大是128),和多线程所占用资源受到限制,因此多线程技术下的并发数量也不会比128高出太多。当响应的客户端成千上万时候,上面的方法就不够用了。即便是线程池技术【为了降低多线程对系统CPU资源的开销,维护指定数量的线程来处理连接,当建立连接之后“池”内指定个数的线程负责和客户端通信,当释放连接或者指定时间内为通信,则“池”接收下一轮客户端连接】也是如此,其本身的规模需要和服务器的连接规模匹配。
现在用非阻塞接口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。 从上可知道 select() 接口可以同时对多个句柄进行读状态、写状态和错误状态的探测,所以可以很容易构建为多个客户端提供独立问答服务的服务器系统。
具体来说,就是用一个 fd_set 结构体来告诉内核同时监控多个文件句柄,当其中有文件句柄的状态发生指定变化(例如某句柄由不可用变为可用)或超时,则调用返回。之后应用可以使用 FD_ISSET 来逐个查看是哪个文件句柄的状态发生了变化。
这样做,小规模的连接问题不大,但当连接数很多(文件句柄个数很多)的时候,逐个检查状态就很慢了。因此,select 往往存在管理的句柄上限(FD_SETSIZE)。同时,在使用上,因为只有一个字段记录关注和发生事件,每次调用之前要重新初始化 fd_set 结构体。这些降低了整个并发系统的效率。
因此就又出现了poll():它通过一个pollfd的数组来通知内核需要注意的事件,内核只需要关注那些发生了指定变化的文件句柄,这样就解决了select()只能处理至多1024个句柄的限制, pollfd 数组向内核传递需要关注的事件消除文件句柄上限,同时使用不同字段分别标注关注事件和发生事件,来避免重复初始化。
为了进一步提高效率,解决需要逐个检查句柄的问题,人们又设计出了epoll,在调用返回的时候,只给调用者返回发生了状态
变化的文件句柄。
至此,单台服务器可以轻松应付上万的连接。现在又有了libevent库,封装了不同操作系统平台下IO的特性,使其具有前述的select(),poll(),epoll()的功能,方便初学者也能完成原先难以完成的任务。
本来准备再对高并发研究一段时间,现在看来简单多了。
目前正处于攻克单机处理千万级并发连接的时期,已经有了一些思路,如
i )内核工作效率不高,尤其是Linux的协议栈复杂和繁琐的,因此可以将网卡传送过来的数据包直接交给应用层程序的业务逻辑单元处理,但这样增加了应用层程序的修改成本,于是我们需要或移植或或定制或优化一个协议栈;
ii )多核CPU面临如何分割任务,任务的同步、异步编程;多线程面临如何节省切换;
iii)内存速度仍然不够快,CPU的L1缓存太小,内存的地址切换也要花不少时间,因此扩大页的大小(从常用的4K增加到2M)。