socket网络编程
在TCP/IP协议中,“IP地址+TCP或UDP端口号”唯一标识网络通讯中的一个进程。“IP地址+端口号”就对应一个socket。欲建立连接的两个进程各自有一个socket来标识,那么这两个socket组成的socket pair就唯一标识一个连接。因此可以用Socket来描述网络连接的一对一关系。
- 一个连接对应两个进程,分别对应客户端和服务器端的socket.
背景知识
大端、小端与网络字节序
内存背景知识:
- 一般32位cpu内存地址长度为32位,由于一个内存地址指向1个字节,所以32位系统可以访问2^32 Byte = 4GB 详解为什么32位系统只能用4G内存.
大端(Big-Endian)
数据的高位更加靠近低地址。以数据0x12345678在内存中的存储为例:
内存地址 | 0x00001000 | 0x00001001 | 0x00001002 | 0x00001003 |
---|---|---|---|---|
存放内容 | 0x12 | 0x34 | 0x56 | 0x78 |
小端(Little-Endian)
数据的低位更加靠近低地址。以数据0x12345678在内存中的存储为例:
内存地址 | 0x00001000 | 0x00001001 | 0x00001002 | 0x00001003 |
---|---|---|---|---|
存放内容 | 0x78 | 0x56 | 0x34 | 0x12 |
ip地址
以6.7.8.9为例, 其对应的32位无符号整形为0x06070809,正常在内存中存储的顺序(这里以小端存储为例子)应为 0x09 0x08 0x07 0x06, 但是经过inet_pton将点分十进制Ip地址转为无符号整形时采用了网络字节序,所以0x06070809 在内存中的存储顺序变为 0x06 0x07 0x08 0x09, 此时通过printf打印此值,会打成0x09080706
详见
tcp 网络编程模型
基本概念:
- 网络io: 等价于客户端与服务器建立连接的socket, 可以通过此socket进行读写io操作,从而传递信息
- 多路复用网络io: 通过多线程等方式实现多个客户端同时访问服务器
服务器端
基本模型如下:
socket(...); //创建socket
bind(...); //绑定ip+端口
listen(...);//监听
while(1)//循环处理客户端的连接请求
{
c_fd = accept(...);//三次握手建立连接
while(1)
{
int nr = read(...);//读取客户端消息
if(nr==0)
{
break;
}
process(...);//业务处理
write(...);//发送处理后的数据
}
close(c_fd);//关闭连接
}
创建socket
类似于open()打开文件,返回文件描述符, 创建socket网络通讯端口, 返回socket的文件描述符,可以像文件一下read/write在网络上进行收发数据.
int socket (int domain, int type, int protocol);
domain
:
- AF_INET 这是大多数用来产生socket的协议,使用TCP或UDP来传输,用IPv4的地址
- AF_INET6 与上面类似,不过是来用IPv6的地址。
- AF_UNIX 本地协议,使用在Unix和Linux系统上,一般都是当客户端和服务器在同一台及其上的时候使用。
type
:
- SOCK_STREAM , 用于TCP可靠传输
- SOCK_DGRAM, 用于UDP不可靠传输
protocol
:
- 0表示默认协议?
绑定ip+端口
- 绑定端口的目的:当内核收到 TCP 报文,通过 TCP 头里面的端口号,来找到我们的应用程序,然后把数据传递给我们.
- 绑定 IP 地址的目的:一台机器是可以有多个网卡的,每个网卡都有对应的 IP 地址,当绑定一个网卡时,内核在收到该网卡上的包,才会发给我们.
将socket与addr ip地址进行绑定, 调用函数:
int bind (int sockfd, const struct sockaddr *addr, socklen_t addrlen);
addr
:
存放ip地址+port端口号的结构体
addrlen
:
=sizeof(addr), 不是sizeof(struct sockaddr)
监听listen
socket被创建出来的时候都默认是一个主动socket,也就说,内核会认为这个socket之后某个时候会调用connect()主动向别的设备发起连接。这个默认对客户端socket来说很合理,但是监听socket可不行,它只能等着客户端连接自己,因此我们需要调用listen()将监听socket从主动设置为被动,明确告诉内核:你要接受指向这个监听socket的连接请求!
监听socket绑定的ip端口号,是否有连接请求
int listen (int sockfd, int backlog);
backlog
:
相当于客户端可以同时连接服务器的个数, 如果超过了怎么办,进入未决队列?
循环处理客户端的连接请求
- 三次握手建立连接
服务端进入了监听状态后,通过调用 accept() 函数,来从内核获取客户端的连接,如果没有客户端连接,则会阻塞直到接收到客户的连接请求等待客户端连接的到来。
客户端对server的连接请求会被放入未决连接队列, 服务端通过accept()函数提取队列中的第一个连接请求, 创建并返回一个已连接 Socket, 原始的监听 Socket并不受影响, 未被处理的连接将在队列中排队
int accept (int sockfd, struct sockaddr *addr, socklen_t *addrlen);
addr
:
传出参数,返回客户端的地址信息,含IP地址和端口号
返回值:
成功返回一个新的socket文件描述符,用于和客户端通信
- 读取客户端消息
read()
- 业务处理
process()
- 发送处理后的数据
write()
将读取客户端消息,业务处理,发送处理后的数据进行封装:
int recv_send(int c_fd)
{
int nr = read(...);//读取客户端消息
if(nr==-1)
{
exit();
}
else if(nr == 0)
{
return nr;
}
process(...);//业务处理
write(...);//发送处理后的数据
}
tcp服务器网络模型简化为:
socket(...); //创建socket
bind(...); //绑定ip+端口
listen(...);//监听
while(1)//循环处理客户端的连接请求
{
c_fd = accept(...);//三次握手建立连接
while(1)
{
int nr = recv_send(...);//读取,处理,并发送消息
if(nr==0)// 客户端断开连接
{
break;
}
}
close(c_fd);//关闭连接
}
关闭连接
close()
客户端
创建socket
向服务器端发送连接请求
客户端在创建好 Socket 后,调用 connect() 函数发起连接,该函数的参数要指明服务端的 IP 地址和端口号, 然后进行TCP三次握手
处理业务逻辑
write
read
关闭连接
参考文章
udp 网络编程模型
与tcp网络模型的区别:
- 创建套接字时的type(参数2)不同。
- TCP通信,使用SOCK_STREAM
- UDP通信,使用SOCK_DGRAM
- 发送数据和接收数据时,使用的接口不同
- TCP通信,发送数据,使用write(或send), 接收数据,使用read(或recv)
- UDP特性,发送数据,使用sendto,接收数据,服务器端使用recvfrom, 客户端使用recv
- 不使用listen
- 不需要先建立连接(TCP客户端和服务器端分别使用connect和receive建立连接)
tcp 多线程网络编程模型
伪代码:
pthread_fun(...)
{
while(1)
{
int nr = recv_send(...);//读取,处理并发送业务消息
if(nr==0)
{
break;
}
}
close(c_fd);//关闭连接
}
socket(...); //创建socket
bind(...); //绑定ip+端口
listen(...);//监听
while(1)//循环处理客户端的连接请求
{
c_fd = accept(...);//三次握手建立连接
pthread_create(..., pthread_fun, (void *)&c_fd); // 创建多线程
}
tcp 多进程网络编程模型
伪代码:
socket(...); //创建socket
bind(...); //绑定ip+端口
listen(...);//监听
while(1)//循环处理客户端的连接请求
{
c_fd = accept(...);//三次握手建立连接
if(fork(...)==0)
{
while(1)
{
int nr = recv_send(...);//读取,处理并发送业务消息
if(nr==0)
{
break;
}
}
close(c_fd);
}
close(c_fd);//关闭连接
}
io多路复用
为什么要使用io多路复用?
以下面这句代码为例:
int iResult = recv(s, buffer,1024);
这是用来接收数据的,在默认的阻塞模式下的套接字里,recv会阻塞在那里,直到套接字连接上有数据可读,把数据读到buffer里后recv函数才会返回,不然就会一直阻塞在那里.
在单线程的程序里出现这种情况会导致主线程(单线程程序里只有一个默认的主线程)被阻塞,这样整个程序被锁死在这里,如果永 远没数据发送过来,那么程序就会被永远锁死.
这个问题可以用多线程解决,但是在有多个套接字连接的情况下,这不是一个好的选择,扩展性很差.
背景
-
多进程模型开销大, 复制所有共享数据, 包括用户空间资源(虚拟内存,栈,全局变量)和系统内核空间(内存堆栈,寄存器等), 可并发~100个客户端
-
多线程模型中, 文件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享些资源在上下文切换时不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据,因此同一个进程下的线程上下文切换的开销要比进程小得多. 但是如果频繁创建和销毁线程,系统开销也是不小的. 线程池的方式来避免线程的频繁创建和销毁,所谓的线程池,就是提前创建若干个线程,这样当由新连接建立时,将这个已连接的 Socket 放入到一个队列里,然后线程池里的线程负责从队列中取出「已连接 Socket 」进行处理.
-
IO多路复用技术可实现单线程并发, 服务器端采用单线程通过select/poll/epoll等系统调用获取fd列表, 遍历有事件的fd进行accept/recv/send,单线程默认可处理1024客户端的并发
select多路复用
工作原理:
传入要监听的文件描述符集合(可读、可写或异常)开始监听,select处于阻塞状态,当有事件发生或设置的等待时间timeout到了就会返回,返回之前自动去除集合中无事件发生的文件描述符,返回时传出有事件发生的文件描述符集合。但select传出的集合并没有告诉用户集合中包括哪几个就绪的文件描述符,需要用户后续进行遍历操作。
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
nfds
:
nfds是最大文件描述符个数,用于遍历
readfds
:
传入传出参数,若不为null,select调用时传入要监听的可读文件描述符集合readfds,select返回时传出发生可读事件的文件描述符集合readfds(发生可读事件的socket置1,否则置0),
select返回一个大于0的值,表示有文件可读;如果没有可读的文件,则根据timeout参数的值再判断是否超时,若超出timeout的时间,select返回0;若发生错误返回负值
writefds
:
传入传出参数,若不为null,select调用时传入要监听的可写文件描述符集合,select返回时传出发生可写事件的文件描述符集合
select返回一个大于0的值,表示有文件可写;如果没有可写的文件,则根据timeout参数的值再判断是否超时,若超出timeout的时间,select返回0;若发生错误返回负值
exceptfds
:
传出参数,若不为null,select返回时传出发生事件(包括可读和可写)中异常事件的文件描述符集合
select返回一个大于0的值,表示有异常发生在文件集合中;如果没有异常发生,则根据timeout参数的值再判断是否超时,若超出timeout的时间,select返回0;若发生错误返回负值
timeout
:
若设置为NULL,则select一直阻塞直到有事件发生;
若设置为0,则select为非阻塞模式,执行后立即返回;
若设置为一个大于0的数,即select的阻塞时间,若阻塞时间内有事件发生就返回,否则时间到了立即返回
fd_set 是自定义数据结构,位域的使用方式, 一个bit位代表一个文件描述符, 以8位为例, 00001000
代表fd=4的文件描述符, 同理10001000
代表fd=4和8的文件描述符被置位.
select多路复用模型伪代码如下:
socket(...);
bind(...);
listen(...);
while(1)
{
select(...);
for(int fd; fd<=maxfd; fd++)
{
if(fd == s_fd)
{
c_fd = accept(...);
FD_SET(c_fd, &readfds);
maxfd = maxfd > c_fd ? maxfd : c_fd;
}
else
{
int nr = recv_send(...);
if(nr==0)
{
close(fd);
FD_CLR(fd, &readfds);
}
}
}
}
poll多路复用
int poll(struct pollfd *fds, unsigned long nfds, int timeout);
返回值:若有就绪描述符则为其数目,若超时则为0,若出错则为-1.
fds
:
struct pollfd类型的数组, 结构体中存储了三个数据:
- 第一个是要监听的文件描述符fd, fd==-1时,不检测此fd
- 第二个是描述符上待检测的时间类型events,如可读POLLIN,可写POLLOUT
- 第三个是检测结果存储, returned events
poll 不会改变fds的值,不需要备份
nfds
:
数组fds的大小, 相当于向poll申请的事件检测的个数
timeout
:
- timeout <0 时, 一直等待, 知道有事件发生
- timeout =0, 直接返回, 很少用
- timeout > 0, 等待的毫秒数
与select相比,可显示控制监控事件的个数(数组fds的大小).
socket(...);
bind(...);
listen(...);
poll_fdset[MAX_SOCKET];
//设置监听socket
poll_fdset[0].fd = listen_fd;
poll_fdset[0].events = POLLIN;
while(1)
{
int numpoll = poll(...);//返回待处理的socket的个数
//处理监听socket
if(poll_fdset[0].revents & POLLIN)
{
int c_fd = accept(...);
//把c_fd加到检测数组中
for (int i = 1; i < NUM_SOCKETS; i++)
{
if (poll_fdset[i].fd < 0)
{
poll_fdset[i].fd = c_fd;
poll_fdset[i].events = POLLIN;
break;
}
}
}
//处理其他连接socket的信息
for(int i=1; i<MAX_SOCKET; i++)
{
if ((c_fd = poll_fdset[i].fd) < 0)
continue;
if (poll_fdset[i].revents & POLLIN)
{
int nr = recv_send(c_fd);
if (nr == 0) /*客户端主动断开连接*/
{
close(c_fd);
poll_fdset[i].fd = -1;
}
if (--numpoll <= 0) /*说明待处理socket已处理完*/
break;
}
}
}
epoll多路复用
要想使用 epoll 模型,必须先需要创建一个 epollfd,这需要使用 epoll_create 函数去创建:
int epoll_create(int size);
int epollfd = epoll_create(1);
有了 epollfd 之后,我们需要将我们需要检测事件的其他 fd 绑定到这个 epollfd 上,或者修改一个已经绑定上去的 fd 的事件类型,或者在不需要时将 fd 从 epollfd 上解绑,这都可以使用 epoll_ctl 函数:
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
epfd
: 通过epoll_create创建的epollfdop
,操作类型,取值有EPOLL_CTL_ADD
、EPOLL_CTL_MOD
和EPOLL_CTL_DEL
,分别表示向 epollfd 上添加、修改和移除一个其他 fd,当取值是EPOLL_CTL_DEL
,第四个参数 event 忽略不计,可以设置为 NULLfd
,即需要被操作的 fdevent
,这是一个 epoll_event 结构体的地址,epoll_event 结构体定义如下:
struct epoll_event
{
uint32_t events; /* 需要检测的 fd 事件,取值与 poll 函数一样 */
epoll_data_t data; /* 用户自定义数据 */
};
- 函数返回值:epoll_ctl 调用成功返回 0,调用失败返回 -1,你可以通过 errno 错误码获取具体的错误原因
创建了 epollfd,设置好某个 fd 上需要检测事件并将该 fd 绑定到 epollfd 上去后,我们就可以调用 epoll_wait 检测事件了,epoll_wait 函数签名如下:
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);//返回事件发生的个数
epfd
:先前创建的epollfdevents
:仅出参, 输出事件就绪的events集合,可直接操作,无需遍历; poll 函数的事件集合调用前后数量都未改变,需要遍历事件的revents字段检查是否有事件发生maxevents
: events数组的大小timeout
:超时时间,单位ms- 返回事件就绪的个数
基本使用模型如下:
while (true)
{
epoll_event epoll_events[1024];
int n = epoll_wait(epollfd, epoll_events, 1024, 1000);
if (n < 0)
{
//被信号中断
if (errno == EINTR)
continue;
//出错,退出
break;
}
else if (n == 0)
{
//超时,继续
continue;
}
for (size_t i = 0; i < n; ++i)
{
// 处理可读事件
if (epoll_events[i].events & POLLIN)
{
if(epoll_events[i].data.fd == listenfd)
{
accept(...);
}
else
{
read(...);
write(...);
}
}
// 处理可写事件
else if (epoll_events[i].events & POLLOUT)
{
}
//处理出错事件
else if (epoll_events[i].events & POLLERR)
{
}
}
}
参考文章: