socket编程
socket这个词可以表示很多概念,在TCP/IP协议中“IP地址 + TCP或UDP端口号”唯一标识网络通讯中的一个进程,“IP + 端口号”就称为socket。在TCP协议中,建立连接的两个进程各自有一个socket来标识,那么两个socket组成的socket pair就唯一标识一个连接。
预备知识
网络字节序:内存中多字节数据相对于内存地址有大端小端之分,磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分。网络数据流同样有大端小端之分,所以发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出,接收主机把从网络上接收到的字节按内存从低到高的顺序保存,因此网络数据流的地址应该规定:先发出的数据是低地址,后发出的数据是高地址。TCP/IP协议规定网络数据流应该采用大端字节序,即低地址高字节。所以发送主机和接收主机是小段字节序的在发送和接收之前需要做字节序的转换。
为了使网络程序具有可移植性可以调用以下函数进行网络字节数的转换。
1 |
|
socket地址数据类型及相关函数
sockaddr数据结构
IPv6和UNIXDomain Socket的地 址类型分别定义为常数AF_INET、AF_INET6、AF_UNIX。这样,只要取得某种sockaddr结构体的首地址,不需要知道具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的 内容。因此,socket API可以接受各种类型的sockaddr结构体指针做参数,例如bind、accept、connect等函数,这些函数的参数应该设计成void *类型以便接受各种类型的指 针,但是sock API的实现早于ANSI C标准化,那时还没有空指针类型这些函数的参数都⽤用struct sockaddr 类型表示,在传递参数之前要强制类型转换一下。
本次只介绍基于IPv4的socket网络编程,sockaddr_in中的成员struct in_addr sin_addr表⽰示32位的IP 地址。但是我们通常⽤用点分十进制的字符串表示IP 地址,以下函数可以在字符串表⽰示 和in_addr表⽰示之间转换。也就是说可以将字符串转换成in_addr类型,也可以将本地字节转换成网络字节。相反由同样有从网络转换到本地的函数具体的用法我们下面的代码中来看。
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int inet_aton(const char *cp, struct in_addr *inp);
in_addr_t inet_addr(const char *cp);
in_addr_t inet_network(const char *cp);
char *inet_ntoa(struct in_addr in);
struct in_addr inet_makeaddr(int net, int host);
in_addr_t inet_lnaof(struct in_addr in)
tcpsocket 实现
实现模型:
1.服务器端 socket -> bind -> listen -> accept(阻塞,三次握手)-> send。
2.客户端 socket -> connect(阻塞,三次握手)-> rcv。
函数介绍:
int socket(int family, int type, int protocol)
family :指定协议的类型本次选择AF_INET(IPv4协议)。
type:网络数据类型,TCP是面向字节流的—SOCK_STREAM.
protocol:前两个参数一般确定了协议类型通常传0.
返回值:成功返回套接字符。
失败返回-1设置相关错误码。
int bind(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen)
sockfd : socket函数成功时候返回的套接字描述符。
servaddr :服务器的IP和端口。
addrlen : 长度(sizeof(servaddr))。
返回值:成功返回0
失败返回-1,并设置相关错误码。
int listen(int sockfd, int backlog)
sockfd: socket函数成功时候返回的套接字描述符。
backlog : 内核中套接字排队的最大个数。
返回值:成功返回0
失败返回-1,并设置相关错误码。
int accept(int sockfd, const struct sockaddr *servaddr, socklen_t *addrlen)
sockfd : socket函数成功时候返回的套接字描述符。
servaddr : 输出型参数,客户端的ip和端口。
addrlen : 长度(sizeof(servaddr))。
返回值:成功:从监听套接字返回已连接套接字
失败:失败返回-1,并设置相关错误码。
int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen)
sockfd:函数返回的套接字描述符
servaddr :服务器的IP和端口
addrlen : 长度(sizeof(servaddr))。
返回值:成功返回0
失败返回-1,并设置相关错误码
实现代码
server.c
1 |
|
client.c
1 |
|
上述代码只可以处理单个用户,为了可以处理多个用户请求我们可以编写多进程或者多线程的TCP套接字。完整代码如下。
多进程TCPsocket
多线程TCPsocket
对于上述代码就是进程或是线程执行到这些函数时必须等待某个事件的发生,如果事件没有发生,进程或线程就被阻塞,函数不能立即在性能上并不是合理的选择,因此我们需要提高代码的性能。下面介绍三种常用的高性能套接字编程方法。
I/O多路复用之select函数
select函数预备知识
struct fd_set可以理解为一个集合,这个集合中存放的是文件描述符(file descriptor),即文件句柄,这可以是我们所说的普通意义的文件,当然Unix下任何设备、管道、FIFO等都是文件形式,全部包括在内,所以毫无疑问一个socket就是一个文件,socket句柄就是一个文件描述符。fd_set集合可以通过一些宏由人为来操作。
(1) FD_CLR(inr fd,fd_set* set):用来清除描述词组set中相关fd 的位
(2)FD_ISSET(int fd,fd_set *set):用来测试描述词组set中相关fd 的位是否为真
FD_SET(int fd,fd_set*set):用来设置描述词组set中相关fd的位
2.struct timeval是一个大家常用的结构,用来代表时间值,有两个成员,一个是秒数,另一个是毫秒数。
FD_ZERO(fd_set *set);用来清除描述词组set的全部位
select函数介绍
int select(int maxfdp,fd_set *readfds,fd_set *writefds,fd_set *errorfds,struct timeval *timeout);
maxfdp : 需要监视的最大文件描述符加1。
readfds、writefds、errorfds:分别对应于需要检测的可读文件描述符的集合,可写文件描述符的集 合及异常文件描述符的集合。
timeout:等待时间,这个时间内,需要监视的描述符没有事件
发⽣生则函数返回,返回值为0。设为NULL 表示阻塞式等待,一直等到有事件就绪,函数才会返回,0表示非阻塞式等待,没有事件就立即返回,大于0表示等待的时间。
返回值:大于0表示就绪时间的个数,等于0表示timeout等待时间到了,小于0表示调用失败。
select函数原理
select系统调用是用来让我们的程序监视多个文件句柄的状态变化的。程序会停在select这⾥里等待,直到被监视的文件句柄有一个或多个发⽣生了状态改变。关于文件句柄,其实就是⼀一个整数,我们最熟悉的句柄是0、1、2三个,0是标准输入,1是标准输出,2是标准错误输出。0、1、2是整数表示的,对应的FILE *结构的表示就是stdin、stdout、stderr。
1.我们通常需要额外定义一个数组来保存需要监视的文件描述符,并将其他没有保存描述符的位置初始化为一个特定值,一般为-1,这样方便我们遍历数组,判断对应的文件描述符是否发生了相应的事件。
2.采用上述的宏操作FD_SET(int fd,fd_set*set)遍历数组将关心的文件描述符设置到对应的事件集合里。并且每次调用之前都需要遍历数组,设置文件描述符。
3.调用select函数等待所关心的文件描述符。有文件描述符上的事件就绪后select函数返回,没有事件就绪的文件描述符在文件描述符集合中对应的位置会被置为0,这就是上述第二步的原因。
4.select 返回值大于0表示就绪的文件描述符的个数,0表示等待时间到了,小于0表示调用失败,因此我们可以遍历数组采用FD_ISSET(int fd,fd_set *set)判断哪个文件描述符上的事件就绪,然后执行相应的操作。
采用select的tcp socket实现代码。
1 |
|
client端同上面tcpsocket端相同。
select的优缺点
优点:
(1)select的可移植性好,在某些unix下不支持poll.
(2)select对超时值提供了很好的精度,精确到微秒,而poll式毫秒。
缺点:
(1)单个进程可监视的fd数量被限制,默认是1024。
(2)需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。
(3)对fd进行扫描时是线性扫描,fd剧增后,IO效率降低,每次调用都对fd进行线性扫描遍历,随着fd的增加会造成遍历速度慢的问题。
(4)select函数超时参数在返回时也是未定义的,考虑到可移植性,每次超时之后进入下一个select之前都要重新设置超时参数。
I/O多路复用之poll函数
poll函数预备知识
不同于select函数poll采用一个pollfd指针向内核传递需要关心的描述符及其相关事件。
fd : 需要关心的文件描述符
events : 需要关心的事件,合法事件如下
POLLIN 有数据可读。
POLLRDNORM 有普通数据可读。
POLLRDBAND 有优先数据可读。
POLLPRI 有紧迫数据可读。
POLLOUT 写数据不会导致阻塞。
POLLWRNORM 写普通数据不会导致阻塞。
POLLWRBAND 写优先数据不会导致阻塞。
POLLMSGSIGPOLL 消息可用。
revents : 关心的事件就绪时 revents会被设置成上述对应的事件,除此之外还可能设置为如下内容。
POLLER 指定的文件描述符发生错误。
POLLHUP 指定的文件描述符挂起事件。
POLLNVAL 指定的文件描述符非法。
poll函数介绍
#include <poll.h>
int poll ( struct pollfd * fds, unsigned int nfds, int timeout);
参数介绍:
fds : 对应上述介绍的结构体指针
nfds : 标记数组中结构体元素的总个数。
timeout : 超时时间 ,等于0表示非阻塞式等待,小于0表示阻塞式等待,大于0表示等待的时间。
返回值:
成功时返回fds数组中事件就绪的文件描述符的个数
返回0表示超时时间到了。
返回-1表示调用失败,对应的错误码会被设置。
EBADF 一个或多个结构体中指定的文件描述符无效。
EFAULTfds 指针指向的地址超出进程的地址空间。
EINTR 请求的事件之前产生一个信号,调用可以重新发起。
EINVALnfds 参数超出PLIMIT_NOFILE值。
ENOMEM 可用内存不足,无法完成请求。
poll函数实现原理
(1)将需要关心的文件描述符放进fds数组中
(2)调用poll函数
(3)函数成功返回后根据返回值遍历fds数组,将关心的事件与结构体中的revents相与判断事件是否就绪。
(4)事件就绪执行相关操作。
poll实现tcpsocket代码
1 |
|
客户端同上。
poll函数的优缺点
优点:
(1)不要求计算最大文件描述符+1的大小。
(2)应付大数量的文件描述符时比select要快。
(3)没有最大连接数的限制是基于链表存储的。
缺点:
(1)大量的fd数组被整体复制于内核态和用户态之间,而不管这样的复制是不是有意义。
(2)同select相同的是调用结束后需要轮询来获取就绪描述符。
I/O多路复用之epoll函数
epoll函数预备知识
epoll函数是多路复用IO接口select和poll函数的增强版本。显著减少程序在大量并发连接中只有少量活跃的情况下CPU利用率,他不会复用文件描述符集合来传递结果,而迫使开发者每次等待事件之前都必须重新设置要等待的文件描述符集合,另外就是获取事件时无需遍历整个文件描述符集合,只需要遍历被内核异步唤醒加入ready队列的描述符集合就行了 。
epoll函数相关系统调用
int epoll_create(int size);
生成一个epoll函数专用的文件描述符,其实是申请一个内核空间,用来存放你想关注的 socket fd 上是否发生以及发生了什么事件。 size 就是你在这个 Epoll fd 上能关注的最大 socket fd 数,大小自定,只要内存足够。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event );
控制文件描述符上的事件,包括注册,删除,修改等操作。
epfd : epoll的专用描述符。
op : 相关操作,通常用以下宏来表示
event : 通知内核需要监听的事件,
EPOLL_CTL_ADD:注册新的fd到epfd中;
EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
EPOLL_CTL_DEL:从epfd中删除⼀一个fd;
fd : 需要监听的事件。结构体格式如下:
events的合法参数如下
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这⾥里应该表⽰示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于⽔水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听⼀一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加⼊入到EPOLL队列里。
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
epfd : epoll特有的文件描述符
events :从内核中的就绪队列中拷贝出就绪的文件描述符。不可以是空指针,内核只负责将数据拷贝到这里,不会为我们开辟空间。
maxevent : 高速内核events有多大,一般不能超过epoll_create传递的size,
timeout : 函数超时时间,0表示非阻塞式等待,-1表示阻塞式等待,函数返回0表示已经超时。
- epoll函数底层实现过程
首先epoll_create创建一个epoll文件描述符,底层同时创建一个红黑树,和一个就绪链表;红黑树存储所监控的文件描述符的节点数据,就绪链表存储就绪的文件描述符的节点数据;epoll_ctl将会添加新的描述符,首先判断是红黑树上是否有此文件描述符节点,如果有,则立即返回。如果没有, 则在树干上插入新的节点,并且告知内核注册回调函数。当接收到某个文件描述符过来数据时,那么内核将该节点插入到就绪链表里面。epoll_wait将会接收到消息,并且将数据拷贝到用户空间,清空链表。对于LT模式epoll_wait清空就绪链表之后会检查该文件描述符是哪一种模式,如果为LT模式,且必须该节点确实有事件未处理,那么就会把该节点重新放入到刚刚删除掉的且刚准备好的就绪链表,epoll_wait马上返回。ET模式不会检查,只会调用一次
- epoll实现tcpsocket代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
static Usage(const char* proc)
{
printf("%s [local_ip] [local_port]\n",proc);
}
int start_up(const char*_ip,int _port)
{
int sock = socket(AF_INET,SOCK_STREAM,0);
if(sock < 0)
{
perror("socket");
exit(2);
}
struct sockaddr_in local;
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = inet_addr(_ip);
if(bind(sock,(struct sockaddr*)&local,sizeof(local)) < 0)
{
perror("bind");
exit(3);
}
if(listen(sock,10)< 0)
{
perror("listen");
exit(4);
}
return sock;
}
int main(int argc, char*argv[])
{
if(argc != 3)
{
Usage(argv[0]);
return 1;
}
int sock = start_up(argv[1],atoi(argv[2]));
int epollfd = epoll_create(256);
if(epollfd < 0)
{
perror("epoll_create");
return 5;
}
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sock;
if(epoll_ctl(epollfd,EPOLL_CTL_ADD,sock,&ev) < 0)
{
perror("epoll_ctl");
return 6;
}
int evnums = 0;//epoll_wait return val
struct epoll_event evs[64];
int timeout = -1;
while(1)
{
switch(evnums = epoll_wait(epollfd,evs,64,timeout))
{
case 0:
printf("timeout...\n");
break;
case -1:
perror("epoll_wait");
break;
default:
{
int i = 0;
for(; i < evnums; ++i)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
if(evs[i].data.fd == sock \
&& evs[i].events & EPOLLIN)
{
int new_sock = accept(sock, \
(struct sockaddr*)&client,&len);
if(new_sock < 0)
{
perror("accept");
continue;
}//if accept failed
else
{
printf("Get a new client[%s]\n", \
inet_ntoa(client.sin_addr));
ev.data.fd = new_sock;
ev.events = EPOLLIN;
epoll_ctl(epollfd,EPOLL_CTL_ADD,\
new_sock,&ev);
}//accept success
}//if fd == sock
else if(evs[i].data.fd != sock && \
evs[i].events & EPOLLIN)
{
char buf[1024];
ssize_t s = read(evs[i].data.fd,buf,sizeof(buf) - 1);
if(s > 0)
{
buf[s] = 0;
printf("client say#%s",buf);
ev.data.fd = evs[i].data.fd;
ev.events = EPOLLOUT;
epoll_ctl(epollfd,EPOLL_CTL_MOD, \
evs[i].data.fd,&ev);
}//s > 0
else
{
close(evs[i].data.fd);
epoll_ctl(epollfd,EPOLL_CTL_DEL, \
evs[i].data.fd,NULL);
}
}//fd != sock
else if(evs[i].data.fd != sock \
&& evs[i].events & EPOLLOUT)
{
char *msg = "HTTP/1.0 200 OK <\r\n\r\n<html><h1>yingying beautiful </h1></html>\r\n";
write(evs[i].data.fd,msg,strlen(msg));
close(evs[i].data.fd);
epoll_ctl(epollfd,EPOLL_CTL_DEL, \
evs[i].data.fd,NULL);
}//EPOLLOUT
else
{
}
}//for
}//default
break;
}//switch
}//while
return 0;
}
epoll函数的优缺点
优点:
epoll的优点:
(1)支持一个进程打开大数目的socket描述符(FD)
select 最不能忍受的是一个进程所打开的FD是有一定限制的,由FD_SETSIZE设置,默认值是2048。对于那些需要支持的上万连接数目的IM服务器来说显 然太少了。这时候你一是可以选择修改这个宏然后重新编译内核,不过资料也同时指出这样会带来网络效率的下降,二是可以选择多进程的解决方案(传统的 Apache方案),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完 美的方案。不过 epoll则没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左 右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。
(2)IO效率不随FD数目增加而线性下降
传统的select/poll另一个致命弱点就是当你拥有一个很大的socket集合,不过由于网络延时,任一时间只有部分的socket是”活跃”的, 但是select/poll每次调用都会线性扫描全部的集合,导致效率呈现线性下降。但是epoll不存在这个问题,它只会对”活跃”的socket进行 操作—这是因为在内核实现中epoll是根据每个fd上面的callback函数实现的。那么,只有”活跃”的socket才会主动的去调用 callback函数,其他idle状态socket则不会,在这点上,epoll实现了一个”伪”AIO,因为这时候推动力在os内核。在一些 benchmark中,如果所有的socket基本上都是活跃的—比如一个高速LAN环境,epoll并不比select/poll有什么效率,相 反,如果过多使用epoll_ctl,效率相比还有稍微的下降。但是一旦使用idle connections模拟WAN环境,epoll的效率就远在select/poll之上了。
(3)使用mmap加速内核与用户空间的消息传递。
这点实际上涉及到epoll的具体实现了。无论是select,poll还是epoll都需要内核把FD消息通知给用户空间,如何避免不必要的内存拷贝就 很重要,在这点上,epoll是通过内核于用户空间mmap同一块内存实现的。而如果你想我一样从2.5内核就关注epoll的话,一定不会忘记手工 mmap这一步的。
(4)内核微调
这一点其实不算epoll的优点了,而是整个linux平台的优点。也许你可以怀疑linux平台,但是你无法回避linux平台赋予你微调内核的能力。 比如,内核TCP/IP协议栈使用内存池管理sk_buff结构,那么可以在运行时期动态调整这个内存pool(skb_head_pool)的大小 — 通过echo XXXX>/proc/sys/net/core/hot_list_length完成。再比如listen函数的第2个参数(TCP完成3次握手 的数据包队列长度),也可以根据你平台内存大小动态调整。更甚至在一个数据包面数目巨大但同时每个数据包本身大小却很小的特殊系统上尝试最新的NAPI网 卡驱动架构。