Linux网络编程详解
网络编程
一、Socket 编程
Socket 编程大致的工作流程
![]() | ![]() |
|---|---|
| \1. 创建一个TCP服务端的流程 |
- 建立一个 socket
*//参数:IPV4 数据流类型 TCP类型*
SOCKET \_mysocket = socket(AF\_INET,SOCK\_STREAM,IPPROTO\_TCP);- 绑定网络端口和IP地址
//绑定网络端口和IP地址
sockaddr\_in \_myaddr = {};//建立sockaddr结构体 sockaddr\_in结构体方便填写 但是下面要进行类型转换
\_myaddr.sin\_family = AF\_INET;//IPV4
\_myaddr.sin\_port = htons(8888);//端口 host to net unsigned short
\_myaddr.sin\_addr.S\_un.S\_addr = inet\_addr("127.0.0.1");//网络地址 INADDR\_ANY监听所有网卡的端口
//参数:socket,(强制转换)sockaddr结构体,结构体大小
if(SOCKET\_ERROR == bind(\_mysocket,(sockaddr\*)&\_myaddr,sizeof(sockaddr\_in))){}- 监听网络端口
*//监听网络端口*
*//参数:套接字 最大多少人连接*
if(SOCKET\_ERROR == listen(\_mysocket,5)){}- 等待客户端链接、接受客户端消息,响应客户端消息
//等待接收客户端连接
sockaddr\_in \_clientAddr = {};//新建sockadd结构体接收客户端数据
int \_addr\_len = sizeof(sockaddr\_in);//获取sockadd结构体长度
SOCKET \_temp\_socket = INVALID\_SOCKET;//声明客户端套接字
char \_buf[256] = {};//接收客户端发送的消息
while(true)
{
*//参数:自身套接字 客户端结构体 结构体大小*
\_temp\_socket = accept(\_mysocket,(sockaddr\*)&\_clientAddr,&\_addr\_len);
if(INVALID\_SOCKET == \_temp\_socket)//接收失败
{
cout<<"接收到无效客户端Socket"<<endl;
}
else
{
cout<<"新客户端加入"<<endl;
printf("IP地址为:%s \n", inet\_ntoa(\_clientAddr.sin\_addr));
}
//接收客户端发送的数据
char \_buf[256] = {};
int \_buf\_len = recv(\_temp\_socket,\_buf,256,0);
if(\_buf\_len>0)
{
printf("%s\n",\_buf);
}
//向客户端发送数据
char \_msg[] = "HelloWorld";
send(\_temp\_socket,\_msg,strlen(\_msg)+1,0);//客户端套接字 数据 数据长短 flag
//关闭客户端socket
closesocket(\_temp\_socket);
}\2. 创建一个TCP客户端的流程
- 建立一个套接字 (socket)
SOCKET _mysocket = socket(AF_INET,SOCK_STREAM,0);
- 连接服务器 connect
//连接服务器
sockaddr\_in \_sin = {};//sockaddr结构体
\_sin.sin\_family = AF\_INET;//IPV4
\_sin.sin\_port = htons(8888);//想要连接的端口号
\_sin.sin\_addr.S\_un.S\_addr = inet\_addr("127.0.0.1");//想要连接的IP
if(SOCKET\_ERROR == connect(\_mysocket,(sockaddr\*)&\_sin,sizeof(sockaddr\_in)))
{
cout<<"连接失败"<<endl;
closesocket(\_mysocket);
}- 向客户端发送数据 (send)
char \_msg[] = "HelloServer";
// 参数:*客户端套接字 数据 数据长短 flag*
send(\_mysocket,\_msg,strlen(\_msg)+1,0);- 接收客户端发送的数据 (recv)
*//接收服务器信息*
char \_buf[256] = {};
int \_buf\_len = recv(\_mysocket,\_buf,256,0);
if(\_buf\_len>0)
{
printf("%s\n",\_buf);
}- 关闭套接字 (closesocket)
*//关闭socket*
closesocket(\_mysocket);\3. TCP握手过程、半连接和全连接队列

\4. TCP挥手过程

\5. 都发起 close 会怎么处理
\6. 服务端怎么感知客户端关闭tcp连接?
- 客户端正常发起关闭,发送FIN报文到服务端后,服务端的内核会在FIN报文插入一个结束符EOF并放在内核的接收缓冲区中,后续在服务端应用程序调用read()读取内核缓冲区的数据,感知到客户端的FIN报文后,read()会返回0,这样就代表这一条TCP连接断开。
- 客户端宕机,服务端一段时间后会触发TCP保活机制,探测客户端的心跳,如果最后判断客户端无心跳,服务端会自动释放链接。
二、I/O模型
\1. linux的五种IO模型
同步IO模型 1. 阻塞IO 2. 非阻塞IO 3. IO多路复用(事件驱动IO) 4. 信号驱动 | 同步异步的区别 将就绪的数据从内核拷贝到用户空间时,用户进程会阻塞就是同步IO,不会就是异步IO。
| 异步IO模型 1. 异步IO |
|---|
- **阻塞I/0:**这是最常见的I/0模型。在此模式中,当应用程序执行I/0操作时,如果数据还没有准备好,应用程序就会被阻塞(挂起),直到数据准备好为止。这期间,应用程序不能做其他事情。
- **非阻塞I/0:**在此模式中,如果I/0操作的数据还没有准备好,操作会立即返回一个错误,而不是阻塞应用程序。应用程序可以继续执行其他操作,也可以反复尝试该I/0操作。
- **I/0多路复用:**也常称为事件驱动I/0。在此模式中,应用程序可以同时监控多个I/0描述符(比如,socket),当任何一个I/0描述符准备好数据时,应用程序就可以对其进行处理。这可以在一个单独的进程或线程中同时处理多个I/0操作,并且不需要阻塞或轮询。select、poll、epoll都是这种模型的实现。
- **信号驱动:**在此模型中,应用程序可以向操作系统注册一个信号处理函数当数据准备好时,操作系统会发送一个信号,应用程序可以在接收到信号时读取数据。这种模式避免了阻塞和轮询,但是编程复杂性较高。
- **异步I/0:**在此模型中,应用程序发起I/0操作后,可以立即开始做其他事情,当数据准备好时,操作系统会将数据复制到应用程序的缓冲区,并通知应用程序。这种模型的优点是应用程序不需要等待I/0操作的完成,缺点是编程复杂性较高。
\2. 阻塞和非阻塞IO应用场景
计算密集型的场景适合阻塞IO,不会一直轮询占用cpu资源。
IO密集型的时候用非阻塞IO,比如视频传输,瓶颈不是cpu,所以用非阻塞IO模型可以避免阻塞在传输函数上,提高程序并发性和响应时间。
\3. 理解I/O多路复用
- 不使用多路复用时:
如果不使用 I/0 多路复用,服务端要并发处理多个客户端的 I/0 事件的话,需要通过创建子进程或者线程的方式来实现,也就是针对每一个连接的I/0 事件要需要一个子进程或者线程来处理,但是随着客户端越来越多,意味着服务端需要创建更多的子进程或者线程,这样对系统的开销太大了。
- 使用了多路复用:
那么有了 I/0 多路复用就可以解决这个问题,I/0 多路复用可以实现是多个I/0复用一个进程,也就是只需要一个进程就能并发处理多个客户端的 I/0 事件,进程可以通过select、poll、epoll这类 I/0 多路复用系统调用接口从内核中获取有事件发生的 socket 集合,然后应用程序就可以遍历这个集合,对每一个 socket 事件进行处理。
Redis 单线程也能做到高性能的原因,也跟 I/0 多路复用有关系
\4. IO多路复用的 3 个实现
\1. select
- select 实现多路复用的方式:
将已连接的 Socket 都放到一个文件描述符集合(用户态),然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生。
检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。

select调用过程
- 缺点
- select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。
- 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大;
- 对 socket 扫描时是线性扫描,采用轮询的方法,效率较低
- select 使用示例
**int** **main**() {
*/\**
\* 这里进行一些初始化的设置,
\* 包括socket建立,地址的设置等,
\*/
fd\_set read\_fs, write\_fs;
**struct** **timeval** timeout;
**int** max = 0; *// 用于记录最大的fd,在轮询中时刻更新即可*
*// 初始化比特位*
FD\_ZERO(&read\_fs);
FD\_ZERO(&write\_fs);
**int** nfds = 0; *// 记录就绪的事件,可以减少遍历的次数*
**while** (1) {
*// 阻塞获取*
*// 每次需要把fd从用户态拷贝到内核态*
nfds = select(max + 1, &read\_fd, &write\_fd, NULL, &timeout);
*// 每次需要遍历所有fd,判断有无读写事件发生*
**for** (**int** i = 0; i <= max && nfds; ++i) {
**if** (i == listenfd) {
--nfds;
*// 这里处理accept事件*
FD\_SET(i, &read\_fd);*//将客户端socket加入到集合中*
}
**if** (FD\_ISSET(i, &read\_fd)) {
--nfds;
*// 这里处理read事件*
}
**if** (FD\_ISSET(i, &write\_fd)) {
--nfds;
*// 这里处理write事件*
}
}
}\2. poll
- 实现方式
- 用动态数组存储关注的fd,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。
poll和 select并没有太大的本质区别,都是使用线性结构存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合。- 缺点
- 每次调用 poll ,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大;
- 对 socket 扫描时是线性扫描,采用轮询的方法,效率较低
- poll使用示例
*// 先宏定义长度*
**#define MAX\_POLLFD\_LEN 4096**
**int** **main**() {
*/\**
\* 在这里进行一些初始化的操作,
\* 比如初始化数据和socket等。
\*/
**int** nfds = 0;
pollfd fds[MAX\_POLLFD\_LEN];
memset(fds, 0, sizeof(fds));
fds[0].fd = listenfd;
fds[0].events = POLLRDNORM;
**int** max = 0; *// 队列的实际长度,是一个随时更新的,也可以自定义其他的*
**int** timeout = 0;
**int** current\_size = max;
**while** (1) {
*// 阻塞获取*
*// 每次需要把fd从用户态拷贝到内核态*
nfds = poll(fds, max+1, timeout);
**if** (fds[0].revents & POLLRDNORM) {
*// 这里处理accept事件*
connfd = accept(listenfd);
*//将新的描述符添加到读描述符集合中*
}
*// 每次需要遍历所有fd,判断有无读写事件发生*
**for** (**int** i = 1; i < max; ++i) {
**if** (fds[i].revents & POLLRDNORM) {
sockfd = fds[i].fd
**if** ((n = read(sockfd, buf, MAXLINE)) <= 0) {
*// 这里处理read事件*
**if** (n == 0) {
close(sockfd);
fds[i].fd = -1;
}
} **else** {
*// 这里处理write事件*
}
**if** (--nfds <= 0) {
**break**;
}
}
}
}\3. epoll
\1. 学习一下 epoll 的用法
先用epoll_create 创建一个 epoll对象 epfd,
再通过 epoll_ctl 将需要监视的 socket 添加到epfd中,
最后调用 epoll_wait 等待数据。
/// 创建socket服务端...
// 使用epoll获取网络事件
int epfd = epoll\_create(...);
epoll\_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中
while(1) {
*// 阻塞获取*
int n = epoll\_wait(...);
for(接收到数据的socket){
//处理
}
}
**int** **main**(**int** argc, **char**\* argv[])
{
*/\**
\* 在这里进行一些初始化的操作,
\* 比如初始化数据和socket等。
\*/
int s = socket(AF\_INET, SOCK\_STREAM, 0);
bind(s, ...);
listen(s, ...)
*// 内核中创建ep对象*
epfd = epoll\_create(256);
*// 需要监听的socket放到ep中*
epoll\_ctl(epfd,EPOLL\_CTL\_ADD,listenfd,&ev);
**while**(1) {
*// 阻塞获取*
nfds = epoll\_wait(epfd,events,20,0);
**for**(i=0;i<nfds;++i) {
**if**(events[i].data.fd==listenfd) {
*// 这里处理accept事件*
connfd = accept(listenfd);
*// 接收新连接写到内核对象中*
epoll\_ctl(epfd,EPOLL\_CTL\_ADD,connfd,&ev);
} **else** **if** (events[i].events&EPOLLIN) {
*// 这里处理read事件*
read(sockfd, BUF, MAXLINE);
*//读完后准备写*
epoll\_ctl(epfd,EPOLL\_CTL\_MOD,sockfd,&ev);
} **else** **if**(events[i].events&EPOLLOUT) {
*// 这里处理write事件*
write(sockfd, BUF, n);
*//写完后准备读*
epoll\_ctl(epfd,EPOLL\_CTL\_MOD,sockfd,&ev);
}
}
}
**return** 0;
}\2. 实现方式
epoll 通过下面2个数据结构,高效解决了select和poll的缺点
红黑树
epoll 在内核里使用红黑树来跟踪进程所有待检测的fd,把需要监控的 socket 通过epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。
而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
链表维护就绪事件
内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
注意:epoll_wait会调用put_user函数,将数据从内核拷贝到用户空间

\3. 关注 eventpoll 结构体
*// 数据结构*
*// 每一个epoll对象都有一个独立的eventpoll结构体*
*// 用于存放通过epoll\_ctl方法向epoll对象中添加进来的事件*
*// epoll\_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可*
**struct** **eventpoll** {
*/\*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件\*/*
**struct** **rb\_root** rbr;
*/\*双链表中则存放着将要通过epoll\_wait返回给用户的满足条件的事件\*/*
**struct** **list\_head** rdlist;
};
*// API*
*// 内核中间加一个 **eventpoll** 对象,把所有需要监听的 socket 都放到 **eventpoll** 对象中*
**int** **epoll\_create**(**int** size);
*// epoll\_ctl 负责把 socket 增加、删除到内核红黑树*
**int** **epoll\_ctl**(**int** epfd, **int** op, **int** fd, **struct** epoll\_event \*event);
*// epoll\_wait 负责检测可读队列,没有可读 socket 则阻塞进程*
**int** **epoll\_wait**(**int** epfd, **struct** epoll\_event \* events, **int** maxevents, **int** timeout);\4. 优缺点
- epoll的优点
- 没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口);
- 效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;即Epoll最大的优点就在于它只管你"活跃"的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll;
- 内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。
- epoll缺点
- epoll只能工作在 linux
\5. 边缘触发和水平触发
- 水平触发:用户程序每次调用epoll_wait时,只要文件描述符处于就绪状态(例如有数据可读),epoll_wait都会返回这个事件。
- 边缘触发:用户程序只有在文件描述符的状态发生变化时(例如从不可读变为可读)调用epoll_wait时才会返回这个事件。即使文件描述符仍然处于就绪状态,如果用户程序没有处理完这个事件,epoll_wait也不会再次返回这个事件,直到状态再次变化。


