C/C++网络编程,从socket到epoll


虚拟机安装 ssh

1.安装命令:
sudo apt-get install openssh-server
安装完成,服务默认已经开启,可以远程ssh连接了。

2.查看ssh服务状态:
sudo service ssh status

3.ssh服务重启命令:
sudo service ssh restart

4.ssh服务的配置文件,可以修改服务端口,权限控制等
sudo gedit /etc/ssh/sshd_config

vi 常用命令

一、关于 vi

vi 是最强大的文本编辑器,没有之一。尽管 vi 已经是古董级的软件,但还是有无数新人迎着困难去学习使用,可见其经典与受欢迎的程度。

无论是小说中还是电视剧,真正强大的武器都不容易驾驭,需要付出一些努力才能收获到更加强大的力量,对于 vi 这种**上古神器**来说更是如此。由于它全程使用键盘操作,很多首次接触 vi 的人会觉得不习惯而中途放弃。然而,坚持下来的朋友就会渐渐地发现这种键盘操作的设计绝妙,经典之所以能成为经典,必然有它的道理,不用解释太多。

观察一个程序员对 vi 的熟练程度,可以判断它的技术水平,如果他对 vi 不熟悉,就肯定不是 Linux 平台下的程序员,说 vi 不好用的人也肯定不熟悉 vi 和 Linux,没有例外。

二、创建/打开文件

vi 文件名

打开一个文件,如果文件不存在,就创建它。

示例:

vi book.c

三、vi 的三种模式

vi 有三种模式,命令行模式、插入模式和替换模式,在命令行模式下,任何键盘输入都是命令,在插入模式和替换模式下,键盘输入的才是字符。

插入模式和替换模式也合称为编辑模式。

四、vi 的常用命令

Esc 从编辑模式切换到命令行模式。

i 在光标所在位置前面开始插入。

a 在光标所在的位置后面开始插入。

o 在光标所在位置行的下面插入空白行。

O 在光标所在位置行的上面插入空白行。

I 在光标所在位置行的行首开始插入。

A 在光标所在位置行的行末开始插入。

k 类似方向键上。

j 类似方向键下。

h 类似方向键左。

l 类是方向键右。

Ctrl+u 向上翻半页。

Ctrl+d 向下翻页。

Ctrl+g 显示光标所在位置的行号和文件的总行数。

nG 光标跳到文件的第 n 行行首。

G 光标跳到文件最后一行。

:5 回车 光标跳到第 5 行。

:n 回车 光标跳到第 n 行。

0 光标跳到当前行的行首。

$ 光标跳到当前行的行尾。

w 光标跳到下个单词的开头。

b 光标跳到上个单词的开头。

e 光标跳到本单词的尾部。

x 每按一次,删除光标所在位置的一个字符。

nx 如”3x”表示删除光标所在位置开始的 3 个字符。

dw 删除光标所在位置到本单词结尾的字符。

D 删除本行光标所在位置后面全部的内容。

dd 删除光标所在位置的一行。

ndd 如”3dd”表示删除光标所在位置开始的 3 行。

yy 将光标所在位置的一行复制到缓冲区。

nyy 将光标所在位置的 n 行复制到缓冲区。

p 将缓冲区里的内容粘贴到光标所在位置。

r 替换光标所在位置的一个字符 replace。

R 从光标所在位置开始替换,直到按下”Esc”。

cw 从光标所在位置开始替换单词,直到按下”Esc”。

u 撤销命令,可多次撤销。

J 把当前行的下一行接到当前行的尾部。

/abcd 在当前打开的文件中查找“abcd”文本内容。

n 查找下一个。

N 查找上一下。

. 重复执行上一次执行的 vi 命令。

~ 对光标当前所在的位置的字符进行大小写转换。

列操作

Ctrl+V 光标上或下 大写的 I 输入内容 Esc

:w 回车 存盘。

:w!回车 强制存盘。

:wq 回车 存盘退出。

:x 回车 存盘退出。

:q 回车 不存盘退出。

:q!回车 不存盘强制退出。

:g/aaaaaaaaa/s//bbbbbb/g 回车 把文件中全部的 aaaaaaaaa 替换成 bbbbbb。

Ctl+insert 复制鼠标选中的文本,相当于 Ctl+c。

Shift+insert 输出鼠标选中的文本,相当于 Ctl+v。

以上两个命令在 windows 和 UNIX 中是通用的。

vim 粘贴时取消自动换行

当 vim 开启 smartindent 时,对于代码会有自动换行的功效。但是,有时候我们需要在向 vim 中粘贴代码时,需要暂时关闭自动换行的功能。

解决方法:

:set paste

之后进行插入操作,vim 提示变为: – INSERT (paste) –

这时就不再有自动换行。

恢复:

:set nopaste

vim 提示变为:– INSERT –

vim 支持 c/c++ STL 即标准库关键字高亮

手动复制进去

git clone https://github.com/octol/vim-cpp-enhanced-highlight.git /tmp/vim-cpp-enhanced-highlight
mkdir -p ~/.vim/after/syntax/
mv /tmp/vim-cpp-enhanced-highlight/after/syntax/cpp.vim ~/.vim/after/syntax/cpp.vim
rm -rf /tmp/vim-cpp-enhanced-highlight

mac/linux 中 vim 永久显示行号、开启语法高亮

cp /usr/share/vim/vimrc ~/.vimrc

先复制一份 vim 配置模板到个人目录下

注:redhat 改成 cp /etc/vimrc ~/.vimrc

步骤 2

vi ~/.vimrc

进入 insert 模式,在最后加二行

syntax on

set nu!

保存收工。

set nocompatible                 "去掉有关vi一致性模式,避免以前版本的bug和局限

set nu!                                    "显示行号

set guifont=Luxi/ Mono/ 9   " 设置字体,字体名称和字号

filetype on                              "检测文件的类型

set history=1000                  "记录历史的行数

set background=dark          "背景使用黑色

syntax on                                "语法高亮度显示

set autoindent                       "vim使用自动对齐,也就是把当前行的对齐格式应用到下一行(自动缩进)

set cindent                             "(cindent是特别针对 C语言语法自动缩进)

set smartindent                    "依据上面的对齐格式,智能的选择对齐方式,对于类似C语言编写上有用

set tabstop=4                        "设置tab键为4个空格,

set shiftwidth =4                   "设置当行之间交错时使用4个空格

set ai!                                      " 设置自动缩进

set showmatch                     "设置匹配模式,类似当输入一个左括号时会匹配相应的右括号

set guioptions-=T                 "去除vim的GUI版本中得toolbar

set vb t_vb=                            "当vim进行编辑时,如果命令错误,会发出警报,该设置去掉警报

set ruler                                  "在编辑过程中,在右下角显示光标位置的状态行

set nohls                                "默认情况下,寻找匹配是高亮度显示,该设置关闭高亮显示

set incsearch                        "在程序中查询一单词,自动匹配单词的位置;如查询desk单词,当输到/d时,会自动找到第一个d开头的单词,当输入到/de时,会自动找到第一个以ds开头的单词,以此类推,进行查找;当找到要匹配的单词时,别忘记回车

set backspace=2           " 设置退格键可用

注:如果是 mac,更好的办法是直接换掉默认的终端,改用 zsh

vim 显示行号、语法高亮、自动缩进的设置

在 UBUNTU 中 vim 的配置文件存放在/etc/vim 目录中,配置文件名为 vimrc

在 Fedora 中 vim 的配置文件存放在/etc 目录中,配置文件名为 vimrc

在 Red Hat Linux 中 vim 的配置文件存放在/etc 目录中,配置文件名为 vimrc

set nocompatible                 "去掉有关vi一致性模式,避免以前版本的bug和局限
set nu!                                    "显示行号
set guifont=Luxi/ Mono/ 9   " 设置字体,字体名称和字号
filetype on                              "检测文件的类型
set history=1000                  "记录历史的行数
set background=dark          "背景使用黑色
syntax on                                "语法高亮度显示
set autoindent                       "vim使用自动对齐,也就是把当前行的对齐格式应用到下一行(自动缩进)
set cindent                             "(cindent是特别针对 C语言语法自动缩进)
set smartindent                    "依据上面的对齐格式,智能的选择对齐方式,对于类似C语言编写上有用
set tabstop=4                        "设置tab键为4个空格,
set shiftwidth =4                   "设置当行之间交错时使用4个空格
set ai!                                      " 设置自动缩进
set showmatch                     "设置匹配模式,类似当输入一个左括号时会匹配相应的右括号
set guioptions-=T                 "去除vim的GUI版本中得toolbar
set vb t_vb=                            "当vim进行编辑时,如果命令错误,会发出警报,该设置去掉警报
set ruler                                  "在编辑过程中,在右下角显示光标位置的状态行
set nohls                                "默认情况下,寻找匹配是高亮度显示,该设置关闭高亮显示
set incsearch                        "在程序中查询一单词,自动匹配单词的位置;如查询desk单词,当输到/d时,会自动找到第一个d开头的单词,当输入到/de时,会自动找到第一个以ds开头的单词,以此类推,进行查找;当找到要匹配的单词时,别忘记回车
set backspace=2           " 设置退格键可用
修改一个文件后,自动进行备份,备份的文件名为原文件名加“~”后缀
      if has("vms")
      set nobackup
      else
      set backup
      endif

如果设置完成后,发现功能没有起作用,检查一下系统下是否安装了 vim-enhanced 包,查询命令为:

​ $rpm -q vim-enhanced 注意:如果设置好以上设置后,VIM 没有作出相应的动作,那么请你把你的 VIM 升级到最新版,一般只要在终端输入以下命令即可:sudo apt-get install vim

转自:https://blog.csdn.net/chuanj1985/article/details/6873830

C 语言 makefile 文件

apt install make

在软件的工程中的源文件是很多的,其按照类型、功能、模块分别放在若干个目录和文件中,哪些文件需要编译,那些文件需要后编译,那些文件需要重新编译,甚至进行更复杂的功能操作,这就有了我们的系统编译的工具。

在 linux 和 unix 中,有一个强大的实用程序,叫 make,可以用它来管理多模块程序的编译和链接,直至生成可执行文件。

make 程序需要一个编译规则说明文件,称为 makefile,makefile 文件中描述了整个软件工程的编译规则和各个文件之间的依赖关系。

makefile 就像是一个 shell 脚本一样,其中可以执行操作系统的命令,它带来的好处就是我们能够实现“自动化编译”,一旦写好,只要一个 make 命令,整个软件功能就完全自动编译,提高了软件开发的效率。

make 是一个命令工具,是一个解释 makefile 中指令的命令工具,一般来说大多数编译器都有这个命令,使用 make 可以是重新编译的次数达到最小化。

一、makefile 的编写

makefile 文件的规则可以非常复杂,比 C 程序还要复杂,我通过示例来介绍它的简单用法。

文件名:makefile,内容如下:

all:book1 book46

book1:book1.c
        gcc -o book1 book1.c

book46:book46.c _public.h _public.c
        gcc -o book46 book46.c _public.c

clean:
        rm -f book1 book46

第一行

all:book book46

all: 这是固定的写法。

book1 book46 表示需要编译目标程序的清单,中间用空格分隔开,如果清单很长,可以用\换行。

第二行

makefile 文件中的空行就像 C 程序中的空行一样,只是为了书写整洁,没有什么意义。

第三行

book1:book1.c

book1:表示需要编译的目标程序。

如果要编译目标程序 book1,需要依赖源程序 book1.c,当 book1.c 的内容发生了变化,执行 make 的时候就会重新编译 book1。

第四行

gcc -o book1 book1.c

这是一个编译命令,和在操作系统命令行输入的命令一样,但是要注意一个问题,在 gcc 之前要用 tab 键,看上去像 8 个空格,实际不是,一定要用 tab,空格不行。

第六行

book46:book46.c _public.h _public.c

与第三行的含义相同。

book46:表示编译的目标程序。

如果要编译目标程序 book46,需要依赖源程序 book46.c、_public.h 和_public.c 三个文件,只要任何一个的内容发生了变化,执行 make 的时候就会重新编译 book46。

第七行

gcc -o book46 book46.c _public.c

与第四行的含义相同。

第九行

clean:

清除目标文件,清除的命令由第十行之后的脚本来执行。

第十行

rm  -f  book1 book46

清除目标文件的脚本命令,注意了,rm 之前也是一个 tab 键,不是空格。

二、make 命令

makefile 准备好了,在命令提示符下执行 make 就可以编译 makefile 中 all 参数指定的目标文件。

执行 make 编译目标程序:

image.png

再执行一次 make:

image.png

因为全部的目标程序都是最新的,所以提示没有目标可以编译。

执行 make clean,执行清除目标文件的指令。

image.png

再执行 make 重新编译。

image.png

修改_public.c 程序,随便改点什么,只要改了就行。

然后再执行 make:

image.png

注意了,因为 book46 依赖的源程序之一_public.c 改变了,所以 book46 重新编译。

book1 没有重新编译,因为 book1 依赖的源文件并没有改变。

三、makefile 文件中的变量

makefile 中,变量就是一个名字,变量的值就是一个文本字符串。在 makefile 中的目标,依赖,命令或其他地方引用变量时,变量会被它的值替代。

我通过示例来介绍它的简单用法。

CC=gcc
FLAG=-g

all:book1 book46

book1:book1.c
        $(CC) $(FLAG) -o book1 book1.c

book46:book46.c _public.h _public.c
        $(CC) $(FLAG) -o book46 book46.c _public.c

clean:
        rm -f book1 book46

第一行

CC=gcc

定义变量 CC,赋值 gcc。

第二行

FLAG=-g

定义变量 FLAG,赋值-g。

第七行

$(CC)  $(FLAG) -o book1 book1.c

$(CC)和$(FLAG)就是使用变量 CC 和 FLAG 的值,类似于 C 语言的宏定义,替换后的结果是:

gcc -g -o book1 book1.c

编译效果:

image.png

在 makefile 文件中,使用变量的好处有两个:

​ 1)如果在很多编译指令采用了变量,只要修改变量的值,就相当于修改全部的编译指令;

​ 2)把比较长的、公共的编译指令采用变量来表示,可以让 makefile 更简洁。

四、应用经验

makefile 文件的编写可以很复杂,复杂到我不想看,在实际开发中,用不着那么复杂的 makefile,我追求简单实用的方法,腾出更多的时间和精力去做更重要的事情,那些把 makefile 文件写得很复杂的程序员在我看来是吃饱了撑的。

五、课后作业

把您这段时间写的程序全部编写到 makefile 中,以后再也不要在命令提示符下用 gcc 了。

六、版权声明

C 语言技术网原创文章,转载请说明文章的来源、作者和原文的链接。

来源:C 语言技术网(www.freecplus.net

作者:码农有道

如果文章有错别字,或者内容有错误,或其他的建议和意见,请您联系我们指正,非常感谢!!!

C\C++网络编程,从 socket 到 epoll

学习 Linux 编程前的准备

编译器

apt install gcc-c++

编译 C 程序的命令是 gcc,编译 C++程序的命令是 g++,g++命令和 gcc 命令的用法相同,把 gcc 改为 g++就可以了,我们在学习 C 语言时编写的那些示例程序,基本上都可以用 g++来编译。

g++ -o book1 book1.c

XSHELL ssh vim 語法高亮配置

文件, 默认/当前会话属性, 终端, 终端类型:linux

文件, 默认/当前会话属性, 终端, 键盘, 功能键类型:linux

如何对 xshell 中的输入命令的那一行进行高亮显示呢

修改你登录用户目录下的.bashrc 文件,把 46 行的注释去掉保存后重新登录即可

(如果使用其他用户登录,其下的.bashrc 也要修改,和 xshell 无关)

//img1.sycdn.imooc.com/5e5d2d2c0001168204690193.jpg

重新登录后

//img4.sycdn.imooc.com/5e5d2d9b00015d9804510114.jpg

一、网络通信 socket

socket 就是插座(中文翻译成套接字有点莫名其妙),运行在计算机中的两个程序通过 socket 建立起一个通道,数据在通道中传输。

image.png

socket 把复杂的 TCP/IP 协议族隐藏了起来,对程序员来说,只要用好 socket 相关的函数,就可以完成网络通信。

二、socket 的分类

socket 提供了流(stream)和数据报(datagram)两种通信机制,即流 socket 和数据报 socket。

流 socket 基于 TCP 协议,是一个有序、可靠、双向字节流的通道,传输数据不会丢失、不会重复、顺序也不会错乱。就像两个人在打电话,接通后就在线了,您一句我一句的聊天。

数据报 socket 基于 UDP 协议,不需要建立和维持连接,可能会丢失或错乱。UDP 不是一个可靠的协议,对数据的长度有限制,但是它的速度比较高。就像短信功能,一个人向另一个人发短信,对方不一定能收到。

在实际开发中,数据报 socket 的应用场景极少,本教程只介绍流 socket。

三、客户/服务端模式

在 TCP/IP 网络应用中,两个程序之间通信模式是客户/服务端模式(client/server),客户/服务端也叫作客户/服务器,各人习惯。

image.png

1、服务端的工作流程

1)创建服务端的 socket。

2)把服务端用于通信的地址和端口绑定到 socket 上。

3)把 socket 设置为监听模式。

4)接受客户端的连接。

5)与客户端通信,接收客户端发过来的报文后,回复处理结果。

6)不断的重复第 5)步,直到客户端断开连接。

7)关闭 socket,释放资源。

服务端示例(server.cpp)

/*
 * 程序名:server.cpp,此程序用于演示socket通信的服务端
 * 作者:C语言技术网(www.freecplus.net) 日期:20190525
*/
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>

int main(int argc,char *argv[])
{
  if (argc!=2)
  {
    printf("Using:./server port\nExample:./server 5005\n\n"); return -1;
  }

  // 第1步:创建服务端的socket。
  int listenfd;
  if ( (listenfd = socket(AF_INET,SOCK_STREAM,0))==-1) { perror("socket"); return -1; }

  // 第2步:把服务端用于通信的地址和端口绑定到socket上。
  struct sockaddr_in servaddr;    // 服务端地址信息的数据结构。
  memset(&servaddr,0,sizeof(servaddr));
  servaddr.sin_family = AF_INET;  // 协议族,在socket编程中只能是AF_INET。
  servaddr.sin_addr.s_addr = htonl(INADDR_ANY);          // 任意ip地址。
  //servaddr.sin_addr.s_addr = inet_addr("192.168.190.134"); // 指定ip地址。
  servaddr.sin_port = htons(atoi(argv[1]));  // 指定通信端口。
  if (bind(listenfd,(struct sockaddr *)&servaddr,sizeof(servaddr)) != 0 )
  { perror("bind"); close(listenfd); return -1; }

  // 第3步:把socket设置为监听模式。
  if (listen(listenfd,5) != 0 ) { perror("listen"); close(listenfd); return -1; }

  // 第4步:接受客户端的连接。
  int  clientfd;                  // 客户端的socket。
  int  socklen=sizeof(struct sockaddr_in); // struct sockaddr_in的大小
  struct sockaddr_in clientaddr;  // 客户端的地址信息。
  clientfd=accept(listenfd,(struct sockaddr *)&clientaddr,(socklen_t*)&socklen);
  printf("客户端(%s)已连接。\n",inet_ntoa(clientaddr.sin_addr));

  // 第5步:与客户端通信,接收客户端发过来的报文后,回复ok。
  char buffer[1024];
  while (1)
  {
    int iret;
    memset(buffer,0,sizeof(buffer));
    if ( (iret=recv(clientfd,buffer,sizeof(buffer),0))<=0) // 接收客户端的请求报文。
    {
       printf("iret=%d\n",iret); break;
    }
    printf("接收:%s\n",buffer);

    strcpy(buffer,"ok");
    if ( (iret=send(clientfd,buffer,strlen(buffer),0))<=0) // 向客户端发送响应结果。
    { perror("send"); break; }
    printf("发送:%s\n",buffer);
  }

  // 第6步:关闭socket,释放资源。
  close(listenfd); close(clientfd);
}

2、客户端的工作流程

1)创建客户端的 socket。

2)向服务器发起连接请求。

3)与服务端通信,发送一个报文后等待回复,然后再发下一个报文。

4)不断的重复第 3)步,直到全部的数据被发送完。

5)第 4 步:关闭 socket,释放资源。

客户端示例(client.cpp)

/*
 * 程序名:client.cpp,此程序用于演示socket的客户端
 * 作者:C语言技术网(www.freecplus.net) 日期:20190525
*/
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>

int main(int argc,char *argv[])
{
  if (argc!=3)
  {
    printf("Using:./client ip port\nExample:./client 127.0.0.1 5005\n\n"); return -1;
  }

  // 第1步:创建客户端的socket。
  int sockfd;
  if ( (sockfd = socket(AF_INET,SOCK_STREAM,0))==-1) { perror("socket"); return -1; }

  // 第2步:向服务器发起连接请求。
  struct hostent* h;
  if ( (h = gethostbyname(argv[1])) == 0 )   // 指定服务端的ip地址。
  { printf("gethostbyname failed.\n"); close(sockfd); return -1; }
  struct sockaddr_in servaddr;
  memset(&servaddr,0,sizeof(servaddr));
  servaddr.sin_family = AF_INET;
  servaddr.sin_port = htons(atoi(argv[2])); // 指定服务端的通信端口。
  memcpy(&servaddr.sin_addr,h->h_addr,h->h_length); // 不能直接赋值
  if (connect(sockfd, (struct sockaddr *)&servaddr,sizeof(servaddr)) != 0)  // 向服务端发起连接清求。
  { perror("connect"); close(sockfd); return -1; }

  char buffer[1024];

  // 第3步:与服务端通信,发送一个报文后等待回复,然后再发下一个报文。
  for (int ii=0;ii<3;ii++)
  {
    int iret;
    memset(buffer,0,sizeof(buffer));
    sprintf(buffer,"这是第%d个超级女生,编号%03d。",ii+1,ii+1);
    if ( (iret=send(sockfd,buffer,strlen(buffer),0))<=0) // 向服务端发送请求报文。
    { perror("send"); break; }
    printf("发送:%s\n",buffer);

    memset(buffer,0,sizeof(buffer));
    if ( (iret=recv(sockfd,buffer,sizeof(buffer),0))<=0) // 接收服务端的回应报文。
    {
       printf("iret=%d\n",iret); break;
    }
    printf("接收:%s\n",buffer);
  }

  // 第4步:关闭socket,释放资源。
  close(sockfd);
}

在运行程序之前,必须保证服务器的防火墙已经开通了网络访问策略(云服务器还需要登录云控制平台开通访问策略)。

先启动服务端程序 server,服务端启动后,进入等待客户端连接状态,然后启动客户端。

客户端的输出如下:

image.png

服务端的输出如下:

image.png

四、注意事项

在 socket 通信的客户端和服务器的程序里,出现了多种数据结构,调用了多个函数,涉及到很多方面的知识,对初学者来说,更重要的是了解 socket 通信的过程、每段代码的用途和函数调用的功能,不要去纠缠这些结构体和函数的参数,这些函数和参数虽然比较多,但可以修改的非常少,别抄错就可以了,需要注意的地方我会提出。

1、socket 文件描述符

在 UNIX 系统中,一切输入输出设备皆文件,socket()函数的返回值其本质是一个文件描述符,是一个整数。

2、服务端程序绑定地址

如果服务器有多个网卡,多个 IP 地址,socket 通信可以指定用其中一个地址来进行通信,也可以任意 ip 地址。

1)指定 ip 地址的代码

m_servaddr.sin_addr.s_addr = inet_addr("192.168.149.129");  // 指定ip地址

2)任意 ip 地址的代码

m_servaddr.sin_addr.s_addr = htonl(INADDR_ANY);  // 本主机的任意ip地址

在实际开发中,采用任意 ip 地址的方式比较多。

3、服务端程序绑定的通信端口

m_servaddr.sin_port = htons(5000);  // 通信端口

4、客户端程序指定服务端的 ip 地址

struct hostent* h;
if ( (h = gethostbyname("118.89.50.198")) == 0 )   // 指定服务端的ip地址。
{ printf("gethostbyname failed.\n"); close(sockfd); return -1; }

5、客户端程序指定服务端的通信端口

servaddr.sin_port = htons(5000);

6、send 函数

send 函数用于把数据通过 socket 发送给对端。不论是客户端还是服务端,应用程序都用 send 函数来向 TCP 连接的另一端发送数据。

函数声明:

ssize_t send(int sockfd, const void *buf, size_t len, int flags);

sockfd 为已建立好连接的 socket。

buf 为需要发送的数据的内存地址,可以是 C 语言基本数据类型变量的地址,也可以数组、结构体、字符串,内存中有什么就发送什么。

len 需要发送的数据的长度,为 buf 中有效数据的长度。

flags 填 0, 其他数值意义不大。

函数返回已发送的字符数。出错时返回-1,错误信息 errno 被标记。

注意,就算是网络断开,或 socket 已被对端关闭,send 函数不会立即报错,要过几秒才会报错。

如果 send 函数返回的错误(<=0),表示通信链路已不可用。

7、recv 函数

recv 函数用于接收对端 socket 发送过来的数据。

recv 函数用于接收对端通过 socket 发送过来的数据。不论是客户端还是服务端,应用程序都用 recv 函数接收来自 TCP 连接的另一端发送过来数据。

函数声明:

ssize_t recv(int sockfd, void *buf, size_t len, int flags);

sockfd 为已建立好连接的 socket。

buf 为用于接收数据的内存地址,可以是 C 语言基本数据类型变量的地址,也可以数组、结构体、字符串,只要是一块内存就行了。

len 需要接收数据的长度,不能超过 buf 的大小,否则内存溢出。

flags 填 0, 其他数值意义不大。

函数返回已接收的字符数。出错时返回-1,失败时不会设置 errno 的值。

如果 socket 的对端没有发送数据,recv 函数就会等待,如果对端发送了数据,函数返回接收到的字符数。出错时返回-1。如果 socket 被对端关闭,返回值为 0。

如果 recv 函数返回的错误(<=0),表示通信通道已不可用。

8、服务端有两个 socket

对服务端来说,有两个 socket,一个是用于监听的 socket,还有一个就是客户端连接成功后,由 accept 函数创建的用于与客户端收发报文的 socket。

9、程序退出时先关闭 socket

socket 是系统资源,操作系统打开的 socket 数量是有限的,在程序退出之前必须关闭已打开的 socket,就像关闭文件指针一样,就像 delete 已分配的内存一样,极其重要。

值得注意的是,关闭 socket 的代码不能只在 main 函数的最后,那是程序运行的理想状态,还应该在 main 函数的每个 return 之前关闭。

五、相关的库函数

1、socket 函数

socket 函数用于创建一个新的 socket,也就是向系统申请一个 socket 资源。socket 函数用户客户端和服务端。

函数声明:

int socket(int domain, int type, int protocol);

参数说明:

domain:协议域,又称协议族(family)。常用的协议族有 AF_INET、AF_INET6、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 传输协议。

说了一大堆废话,第一个参数只能填 AF_INET,第二个参数只能填 SOCK_STREAM,第三个参数只能填 0。

除非系统资料耗尽,socket 函数一般不会返回失败。

返回值:成功则返回一个 socket,失败返回-1,错误原因存于 errno 中。

2、gethostbyname 函数

把 ip 地址或域名转换为 hostent 结构体表达的地址。

函数声明:

struct hostent *gethostbyname(const char *name);

参数 name,域名或者主机名,例如”192.168.1.3”、”www.freecplus.net"等。

返回值:如果成功,返回一个 hostent 结构指针,失败返回 NULL。

gethostbyname 只用于客户端。

gethostbyname 只是把字符串的 ip 地址转换为结构体的 ip 地址,只要地址格式没错,一般不会返回错误。失败时不会设置 errno 的值。

3、connect 函数

向服务器发起连接请求。

函数声明:

int connect(int sockfd, struct sockaddr * serv_addr, int addrlen);

函数说明:connect 函数用于将参数 sockfd 的 socket 连至参数 serv_addr 指定的服务端,参数 addrlen 为 sockaddr 的结构长度。

返回值:成功则返回 0,失败返回-1,错误原因存于 errno 中。

connect 函数只用于客户端。

如果服务端的地址错了,或端口错了,或服务端没有启动,connect 一定会失败。

4、bind 函数

服务端把用于通信的地址和端口绑定到 socket 上。

函数声明:

int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);

参数 sockfd,需要绑定的 socket。

参数 addr,存放了服务端用于通信的地址和端口。

参数 addrlen 表示 addr 结构体的大小。

返回值:成功则返回 0,失败返回-1,错误原因存于 errno 中。

如果绑定的地址错误,或端口已被占用,bind 函数一定会报错,否则一般不会返回错误。

5、listen 函数

listen 函数把主动连接 socket 变为被动连接的 socket,使得这个 socket 可以接受其它 socket 的连接请求,从而成为一个服务端的 socket。

函数声明:

int listen(int sockfd, int backlog);

返回:0-成功, -1-失败

参数 sockfd 是已经被 bind 过的 socket。socket 函数返回的 socket 是一个主动连接的 socket,在服务端的编程中,程序员希望这个 socket 可以接受外来的连接请求,也就是被动等待客户端来连接。由于系统默认时认为一个 socket 是主动连接的,所以需要通过某种方式来告诉系统,程序员通过调用 listen 函数来完成这件事。

参数 backlog,这个参数涉及到一些网络的细节,比较麻烦,填 5、10 都行,一般不超过 30。

当调用 listen 之后,服务端的 socket 就可以调用 accept 来接受客户端的连接请求。

返回值:成功则返回 0,失败返回-1,错误原因存于 errno 中。

listen 函数一般不会返回错误。

6、accept 函数

服务端接受客户端的连接。

函数声明:

int accept(int sockfd,struct sockaddr *addr,socklen_t *addrlen);

参数 sockfd 是已经被 listen 过的 socket。

参数 addr 用于存放客户端的地址信息,用 sockaddr 结构体表达,如果不需要客户端的地址,可以填 0。

参数 addrlen 用于存放 addr 参数的长度,如果 addr 为 0,addrlen 也填 0。

accept 函数等待客户端的连接,如果没有客户端连上来,它就一直等待,这种方式称之为阻塞。

accept 等待到客户端的连接后,创建一个新的 socket,函数返回值就是这个新的 socket,服务端使用这个新的 socket 和客户端进行报文的收发。

返回值:成功则返回 0,失败返回-1,错误原因存于 errno 中。

accept 在等待的过程中,如果被中断或其它的原因,函数返回-1,表示失败,如果失败,可以重新 accept。

7、函数小结

服务端函数调用的流程是:socket->bind->listen->accept->recv/send->close

客户端函数调用的流程是:socket->connect->send/recv->close

其中send/recv可以进行多次交互。

六、课后作业

1)把 client.cpp 和 server.cpp 抄下来,编译运行,试试修改参数再运行。

2)client.cpp 和 server.cpp 程序中,有些代码不能动,有些代码可以动,把能动的都动一下,就算是抄代码,也要抄个明白。

3)服务端的 accept 函数会阻塞,阻塞是专业名词,即等待,可以用代码测试一下。

4)不管是服务端还是客户端 recv 函数也会阻塞,可以用代码测试一下。

5)修改 client.cpp 和 server.cpp,实现点对点的聊天功能,用户在客户端输入一个字符串,然后发送给服务端,服务端收到客户端的报文后,也提示用户输入一个字符串,返回给客户端,如果服务端收到客户端的报文是“bye”通信结束。

6)如果以上作业都能完成,建议再把本文章的内容再看一次,对文章开始部分的理论知识将有新的理解。

七、版权声明

C 语言技术网原创文章,转载请说明文章的来源、作者和原文的链接。

来源:C 语言技术网(www.freecplus.net

作者:码农有道

8、经验分享

可以打开多少个 sock
// ulimit -a 1024 个
ubuntu@ubuntu:~/Desktop$ ulimit -a
core file size          (blocks, -c) 0
data seg size           (kbytes, -d) unlimited
scheduling priority             (-e) 0
file size               (blocks, -f) unlimited
pending signals                 (-i) 15399
max locked memory       (kbytes, -l) 65536
max memory size         (kbytes, -m) unlimited
open files                      (-n) 1024
pipe size            (512 bytes, -p) 8
POSIX message queues     (bytes, -q) 819200
real-time priority              (-r) 0
stack size              (kbytes, -s) 8192
cpu time               (seconds, -t) unlimited
max user processes              (-u) 15399
virtual memory          (kbytes, -v) unlimited
file locks                      (-x) unlimited

// 修改为 2000 个
ubuntu@ubuntu:~/Desktop$ ulimit -HSn 2000
设置服务端 socket 的 SO_REUSEADDR 属性

服努端程序的端口释放后可能会处于 TIME WAIT 状态,等待两分钟之后才能再被使用 SO_REUSEADDR 是让端口释放后立即就可以被再次使用。

// 设置 SO_REUSEADDR选项
int opt=1;
unsigned int len=sizeof(opt);
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, len)
查看系统进程连接情况

netstat -na

六、主机字节序与网络字节序

  • 字节顺序

    • 是指占内存多于一个字节类型的数据在内存中的存放顺序,一个 32 位整数由 4 个字节组成。內存中存储这 4 个字节有两种方法:一种是将低序字节存储在起始地址,这称为小端(little-endian)字节序;另一种方法是将高序字节存储在起始地址,这称为大端(big-endian)字节序。

    image-20210705144109361

    • 这两种字节序之间没有标准可循,两种格式都有系统使用。比如,Inter x86、ARM 核采用的是小端模式,Power pc、MIPS UNIX 和 HP-PA UNIX 釆用大端模式。
      大于一个字节类型的数据在内存中的存放有顺序,一个字节的数据没有顺序的问题
  • 网络字节序:网络字节序是 TCP/P 中规定好的一种数据表示格式,它与具体的 CPU 类型、操作系统等无关,从而可以保证数据在不同主机之间传输时能够被正确解释。网络字节序采用 big endian 排序方式

  • 主机字节序:不同的机器主机字节序不相同,与 CPU 设计有关,数据的顺序是由 cpu 决定的,而与操作系统无关。

  • 由于这个原因不同体系结构的机器之间无法通信,所以要转换成一种约定的字节序,也就是网络字节序。即使是同一台机器上的两个进程(比如一个由 C 语言,另一个由 Java 编写)通信,也要考虑字节序的问题(VM 采用大端字节序)。

  • 网络字节序与主机字节序之间的转换函数:

    • hons(),、ntohs()、htonl()、ntohl()
    • htons 和 ntohs 完成 16 位无符号数的相互转换,
    • htonl 和 ntohl 完成 32 位无符号数的相互转换。host to network short long

七、结构体

socket

struct sockaddr{
  unsigned short sa_family;
  char sa_data[14];
}

struct sockaddr_in{
  short int sin_family;
  unsigned short int sin_port;
  struct in addr sin_addr;
  unsigned char sin_zero[8];

}
struct in addr{
	unsigned long s_addr;
}

bind

强制转换为老的 sockaddr

int bind (int sockfd, const struct sockaddr *addr, socklen_t addr len)

gethostbyname

struct hostent {
  char* h_name; //主机名
	char*h_aliases; //主机所有别名构成的字符串数组,同一P可绑定多个域名int h 		int h_addrtype; //主机P地址的类型,例如PV4(AF INET)还是PV6
	int h_length; //主机IP地址长度,IPV4地址为4,IPV6地址则为16
 	char*h addr_list; //主机的jp地址,以网络字节序存储。
}
#define h_addr h_addr_list[0]/*for backward compatibility*/

// gethostbyname函数可以利用字符串格式的域名获得IP网络字节顺序地址。
struct hostent*gethostbyname(const char*name)
  1. 将一个字符串 IP 地址转换为一个 32 位的网络字节序 IP 地址。如果这个函数成功,函数的返回值非零,如果输入地址不正确则会返回零。使用这个函数并没有错误码存放在 errno 中,所以它的值会被忽略
  2. 把网络字节序 IP 地址转换成字符串的 IP 地址。
int inet_aton(const char*cp,struct in_addr*inp);

char*inet_ntoa(struct in_addr in);

in_addr_t inet_addr(const char*cp);// 和第一个相同

listen、connect、accept

返回新的 socket,生成队列。

accept 从队列中获取一个

1)服务端在调用 llisten0 之前,客户端不能向服务端发起连接请求的

2)服务端调用 listen0 函数后,服务端的 socke 开始监听客户端的连接

3)客户端调用 connect0 函数向服务端发起连接请求

4)在 cP 底层,客户端和服务端握手后建立起通信通道,如果有多个客户端请求,在服务端就会形成一个已准备好的连接的队列

5)服务端调用 accept0 函数从队列中获取一个已准备好的连接,函数返回一个新的 socket,新的 socket 用于与客户端通信,listen 的 socket 只负责监听客户端的连接请求。

listen 的 socket 队列

  • 内核会为 ten 状态的 socket 维护两个队列:不完全连接请求队列(SYN RECV 状态)
    和等待 acep 建立 socke 的队列(ESTABLISHED 状态)
  • 在 linux 内核 22 之后,backlog 参数的形为改变了,现在它指等待 accept 的完全建立的 socket 的队列长度,而不是不完全连接请求的数量。不完全连接队列的长度可以使用
    /proc/sys/net/ipv4/tcp_max_syn_backlog 设置(缺省值 128)
  • backlog 参数如果比/proc/sys/net/ipv4/tcp max syn backlog,则截断

八、TCP 报文分包和粘包

分包:发送方发送字符串”helloworld”,接收方却接收到了两个字符串”hello 和”world 粘包:发送方发送两个字符串”hello”+”world”,接收方却一次性接收到了”helloworld”
但是 TcP 传输数据能保证几点:
1)顺序不变,例如发送方发送 hell,接收方也一定顺序接收到 helo,这个是 TCP 协议承诺的,因此这点成为我们解决分包和粘包问题的关键

2)分割的包中间不会插入其他数据。
在实际开发中,为了解决分包和粘包的问题,就一定要自定义一份协议,最常用的方一法是
报文长度+报文内容0010helloworld 报文长度 asci 码,二进制的数字

九、socket 封装

用于解决分包和粘包,封装成类。

十、多进程的 socket 服务端

基础:

  1. 信号;
  2. 多进程;

一、搭建

二、僵尸进程

三、增加业务逻辑

  1. xml 登录和身份验证

四、TCP 长连接与短连接

  1. client 与 server 建立连接进行通信,通信完成后释放连接,建立连接时需要 3 次握手,释放连接需要 4 次挥手,连接的建立和释放都需要时间,server 还有创建新进程或线程的开销。
    1. 短连接:client/server 间只进行一次或连续多次通信,通信完成后马上断开了。管理起来比较简单,不需要额外的控制手段。
    2. 长连接:client/server 间需要多次通信,通信的频率和次数不确定,所以 client 和 server 需要保持这个连接。
      根据不同的应用场景采用不同的策略,没有十全十美的选择,只有合适的选择

十一、网络服务端性能测试

  1. 在实际项目开发中,除了完成程序的功能,还需要测试性能。

    1. 在充分了解服务端的性能后,才能决定如何选择服务端的架构,还有网络带宽、硬件配置等。
    2. 服务端的性能指标是面试中必问的。
    3. 如果不了解系统的性能指标,面试官会认为您没有实际项目开发经验或对网络编程是一知半解。主要的性能指标如下:
      1. 1)服务端的并发能力;
      2. 2)服务端的业务处理能力
      3. 3)客户端业务响应时效;
      4. 4)网络带宽。

    重要的业务系统,最好是与系统管理员和网络管理员一起测试。

一、并发性能测试

二、业务性能测试

三、多进程与多线程性能差异

四、响应时间

五、带宽

十二、I/O 复用

  1. 多进程/线程并发模型,为每个 socket 分配一个进程/线程。
  2. I/O(多路)复用,采用单个进/线程就可以管理多个 socket
    1. 网络设备(交换机、路由器);
    2. 大型游戏的后台
    3. nginx、redis
  3. l/O 复用有三种方案:select、poll、epoll,各有优缺点,select 并不是一定就不行,epoll 也不是什么都好,各有应用场景,必须全部掌握。

一、select

image-20210705224417795

image-20210705235241828

  • select 的水平触发
    • selec 采用“水平触发”的方式,如果报告了 fd 后事件没有被处理或数据没有被全部读取,那么下次 select 时会再次报告该 fd。
  • select 的缺点
    1. 1)select 支持的文件描述符数量太小了,默认是 1024,虽然可以调整,但是,描述符数量越大,效率将更低,调整的意义不大。
    2. 2)每次调用 select,都需要把 fdset 从用户态拷贝到内核。
    3. 3)同时在线的大量客户端有事件发生的可能很少,但还是需要遍历 feet,因此随着监视的描述符数量的增长,其效率也会线性下降。
  • select 的其它用途
    • 在 unⅸ(Linux)世界里,一切皆文件,文件就是一串二进制流,不管 socket、管道、終端、设备等都是文件,一切都是流。在信息交换的过程中,都是对这些流进行数据的收发操作,简称为 Ⅳ◎ 操作 input and output),往流中读出数据,系统调用 read,写入数据,系统调用 write
    • select 是 io 复用函数,除了用于网络通信,还可以用于文件、管道、终端、设备等操作,但开发场景比较少。
      了解 pselect 函数。

二、poll

  1. poll 模型

    • poll 和 select 在本质上没有差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是 poll 没有最大文件描述符数量的限制。
    • select 用 fdset 采用 bitmap,poll 用了数组
    • poll 和 select 同样存在一个缺点就是,文件描述符的数组被整体复制于用户态和内核态的地址空间之间,而不论这些文件描述符是否有事件,它的开销随着文件描述符数量的增加而线性增大。
    • 还有 poll 返回后,也需要历遍整个描述符的数组才能得到有事件的描述符。
  2. poll 函数和参数

    #include <poll.h>
    int poll(struct pollfd *fdarray,unsigned long nfds,int timeout);
    
    struct pollfd{
     int fd;                  //需要检测的文件描述符
     short events;            //请求的事件
     short revents;           //返回的事件
    };
    1. 返回:若有就绪描述符则为其数目,若超时返回 0,出错返回-1

      第一个参数是指向一个结构体数组第一个元素的指针。每个元素都是一个 pollfd 结构,用于指定测试某个给定描述符 fd 的条件

  3. 下表说明了能够作为 events 和 revents 的常量

    img

    结构体数组中元素的个数是由 nfds 参数指定。

    timeout 参数指定 poll 函数返回前等待多长时间。它是一个指定应等待毫秒数的正值。取值如下表:

    timeout 值说明-1 永远等待,直到有描述符就绪 0 立即返回,不阻塞进程>0 等待指定的毫秒数

    使用 poll 函数建立的服务器端如下:

    //
    // Created by silver on 2020/8/23.
    //
    #include <stdio.h>
    #include <stdlib.h>
    #include <arpa/inet.h>
    #include <netinet/in.h>
    #include <sys/unistd.h>
    #include <sys/socket.h>
    #include <sys/types.h>
    #include <errno.h>
    #include <string.h>
    #include <sys/wait.h>
    #include <sys/select.h>
    #include <poll.h>
    #include <sys/stropts.h>
    #define MAXLINE 4096
    #define PORT 9873
    #define LISTQUE 1024
    #define OPEN_MAX 256
    int main(int argc,char* argv[])
    {
        int listenfd,confd,sockfd;
        struct pollfd client[OPEN_MAX];
        int nready;
        int maxcount = -1;
        int count = 0;
        char buf[MAXLINE];
        struct sockaddr_in SerAddr,Cliaddr;
        socklen_t Clilen = sizeof(Cliaddr);
        //initializer
        bzero(&SerAddr,sizeof(SerAddr));
        bzero(&Cliaddr,Clilen);
        bzero(buf,MAXLINE);
        listenfd = socket(AF_INET,SOCK_STREAM,IPPROTO_IP);
        SerAddr.sin_family = AF_INET;
        SerAddr.sin_port = htons(PORT);
        SerAddr.sin_addr.s_addr = htonl(INADDR_ANY);
    
        if (bind(listenfd,(struct sockaddr*)&SerAddr,sizeof(SerAddr)) < 0)
        {
            perror("Bind error");
        }
        if (listen(listenfd,LISTQUE) < 0)
        {
            perror("listen error");
        }
        client[0].fd = listenfd;
        client[0].events = POLLRDNORM;
        for (count = 1; count < OPEN_MAX; count++) {
            client[count].fd = -1;
        }
        maxcount = 0;
        ssize_t  n;
        fputs("server waiting....",stdout);
        fflush(stdout);
        while(true)
        {
            nready = poll(client,maxcount +1,-1);
    
            if (client[0].revents & POLLRDNORM) // new client connection
            {
                confd = accept(listenfd,(struct sockaddr*)&Cliaddr,&Clilen);
                printf("client address: %s\n",inet_ntoa(Cliaddr.sin_addr));
                if (confd < 0){ perror("accept error");}
    
                for (count = 1;  count<OPEN_MAX ; count++)
                {
                    if (client[count].fd < 0)
                    {
                        client[count].fd = confd;
                        break;
                    }
                }
                if (count == OPEN_MAX) {printf("too many clients");exit(-1);}
    
                client[count].events = POLLRDNORM;
                if (count > maxcount) maxcount = count;
                if (--nready <= 0) continue; //no more readable descriptions
            }
            for (count = 1; count <=maxcount ; count++)
            {
                if ((sockfd = client[maxcount].fd) < 0){ continue;}
                if(client[count].revents & (POLLRDNORM | POLLERR))
                {
                    n = read(sockfd,buf,MAXLINE);
                    if (n < 0) {
                        if (errno == ECONNRESET) {
                            close(sockfd);
                            client[count].fd = -1;
                        } else {
                            printf("read error");
                            exit(-1);
                        }
                    } else if (n == 0){
                        close(sockfd);
                        client[count].fd = -1;
                    } else{
                        writen(sockfd,buf,n);
                    }
                }
                if (--nready <= 0) break; //no more readable descriptions
            }
        }
        close(listenfd);
        return 0;
    }

    编辑于 2020-09-12

三、epoll

epoll 解决了 select 和 poll 所有的问题(fdset 拷贝和轮询),采用了最合理的设计和实现方案。

epoll 在现在的软件中占据了很大的分量,nginx,libuv 等单线程事件循环的软件都使用了 epoll。之前分析过 select,今天分析一下 epoll。

们按照 epoll 三部曲的顺序进行分析。

epoll_create
asmlinkage long sys_epoll_create(int size)
{
    int error, fd;
    struct inode *inode;
    struct file *file;

    error = ep_getfd(&fd, &inode, &file);
    error = ep_file_init(file);

    return fd;

}

我们发现 create 函数似乎很简单。
1 操作系统中,进程和文件系统是通过 fd=>file=>node 联系起来的。ep_getfd 就是在建立这个联系。

static int ep_getfd(int *efd, struct inode **einode, struct file **efile)
{

    // 获取一个file结构体
    file = get_empty_filp();
    // epoll在底层本身对应一个文件系统,从这个文件系统中获取一个inode
    inode = ep_eventpoll_inode();
    // 获取一个文件描述符
    fd = get_unused_fd();

    sprintf(name, "[%lu]", inode->i_ino);
    this.name = name;
    this.len = strlen(name);
    this.hash = inode->i_ino;
    // 申请一个entry
    dentry = d_alloc(eventpoll_mnt->mnt_sb->s_root, &this);
    dentry->d_op = &eventpollfs_dentry_operations;
    file->f_dentry = dentry;

    // 建立file和inode的联系
    d_add(dentry, inode);
    // 建立fd=>file的关联
    fd_install(fd, file);

    *efd = fd;
    *einode = inode;
    *efile = file;
    return 0;
}

形成一个这种的结构。

2 通过 ep_file_init 建立 file 和 epoll 的关联。

static int ep_file_init(struct file *file)
{
    struct eventpoll *ep;

    ep = kmalloc(sizeof(struct eventpoll), GFP_KERNEL)
    memset(ep, 0, sizeof(*ep));
    // 一系列初始化
    file->private_data = ep;

    return 0;
}

epoll_create 函数主要是建立一个数据结构。并返回一个文件描述符供后面使用。

epoll_ctl
asmlinkage long
sys_epoll_ctl(int epfd, int op, int fd, struct epoll_event __user *event)
{
    int error;
    struct file *file, *tfile;
    struct eventpoll *ep;
    struct epitem *epi;
    struct epoll_event epds;

    error = -EFAULT;
    // 不是删除操作则复制用户数据到内核
    if (
        EP_OP_HASH_EVENT(op) &&
        copy_from_user(&epds, event, sizeof(struct epoll_event))
      )
        goto eexit_1;

    // 根据一种的图,拿到epoll对应的file结构体
    file = fget(epfd);

    // 拿到操作的文件的file结构体
    tfile = fget(fd);
    // 通过file拿到epoll_event结构体,见上面的图
    ep = file->private_data;
    // 看这个文件描述符是否已经存在,epoll用红黑树维护这个数据
    epi = ep_find(ep, tfile, fd);

    switch (op) {
    // 新增
    case EPOLL_CTL_ADD:
        // 还没有则新增,有则报错
        if (!epi) {
            epds.events |= POLLERR | POLLHUP;
            // 插入红黑树
            error = ep_insert(ep, &epds, tfile, fd);
        } else
            error = -EEXIST;
        break;
    // 删除
    case EPOLL_CTL_DEL:
        // 存在则删除,否则报错
        if (epi)
            error = ep_remove(ep, epi);
        else
            error = -ENOENT;
        break;
    // 修改
    case EPOLL_CTL_MOD:
        // 存在则修改,否则报错
        if (epi) {
            epds.events |= POLLERR | POLLHUP;
            error = ep_modify(ep, epi, &epds);
        } else
            error = -ENOENT;
        break;
    }
}

epoll_ctl 函数看起来也没有很复杂,就是根据用户传进来的信息去操作红黑树。对于红黑树的增删改查,查和删除就不分析了。就是去操作红黑树。增和改是类似的逻辑,所以我们只分析增操作就可以了。在此之前,我们先了解一些 epoll 中其他的数据结构。

当我们新增一个需要监听的文件描述符的时候,系统会申请一个 epitem 去表示。epitem 是保存了文件描述符、事件等信息的结构体。然后把 epitem 插入到 eventpoll 结构体维护的红黑树中。

static int ep_insert(struct eventpoll *ep, struct epoll_event *event,
             struct file *tfile, int fd)
{
    int error, revents, pwake = 0;
    unsigned long flags;
    struct epitem *epi;
    struct ep_pqueue epq;

    // 申请一个epitem
    epi = EPI_MEM_ALLOC()
    // 省略一系列初始化工作
    // 记录所属的epoll
    epi->ep = ep;
    // 在epitem中保存文件描述符fd和file
    EP_SET_FFD(&epi->ffd, tfile, fd);
    // 监听的事件
    epi->event = *event;
    epi->nwait = 0;

    epq.epi = epi;
    init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);
    revents = tfile->f_op->poll(tfile, &epq.pt);

    // 把epitem插入红黑树
    ep_rbtree_insert(ep, epi);

    // 如果监听的事件在新增的时候就已经触发,则直接插入到epoll就绪队列
    if ((revents & event->events) && !EP_IS_LINKED(&epi->rdllink)) {
        // 把epitem插入就绪队列rdllist
        list_add_tail(&epi->rdllink, &ep->rdllist);
        //  有事件触发,唤醒阻塞在epoll_wait的进程队列
        if (waitqueue_active(&ep->wq))
            wake_up(&ep->wq);
        if (waitqueue_active(&ep->poll_wait))
            pwake++;
    }
}

新增操作的大致流程是
1 申请了一个新的 epitem 表示待观察的实体。他保存了文件描述符、感兴趣的事件等信息。
2 插入红黑树
3 判断新增的节点中对应的文件描述符和事件是否已经触发了,是则加入到就绪队列(由 eventpoll->rdllist 维护的一个队列)
下面具体看一下如何判断感兴趣的事件在对应的文件描述符中是否已经触发。相关代码在 ep_insert 中。下面单独拎出来。

/*
    struct ep_pqueue {
        // 函数指针
        poll_table pt;
        // epitem
        struct epitem *epi;
    };
*/
struct ep_pqueue epq;
epq.epi = epi;
init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);
revents = tfile->f_op->poll(tfile, &epq.pt);

static inline void init_poll_funcptr(poll_table *pt, poll_queue_proc qproc)
{
    pt->qproc = qproc;
}

上面的代码是定义了一个 struct ep_pqueue 结构体,然后设置他的一个字段为 ep_ptable_queue_proc。然后执行 tfile->f_op->poll。poll 函数由各个文件系统或者网络协议实现。我们以管道为例。

static unsigned int
pipe_poll(struct file *filp, poll_table *wait)
{
    unsigned int mask;
    // 监听的文件描述符对应的inode
    struct inode *inode = filp->f_dentry->d_inode;
    struct pipe_inode_info *info = inode->i_pipe;
    int nrbufs;
    /*
    static inline void poll_wait(struct file * filp, wait_queue_head_t * wait_address, poll_table *p)
    {
        if (p && wait_address)
            p->qproc(filp, wait_address, p);
    }
    */
    poll_wait(filp, PIPE_WAIT(*inode), wait);

    // 判断哪些事件触发了
    nrbufs = info->nrbufs;
    mask = 0;
    if (filp->f_mode & FMODE_READ) {
        mask = (nrbufs > 0) ? POLLIN | POLLRDNORM : 0;
        if (!PIPE_WRITERS(*inode) && filp->f_version != PIPE_WCOUNTER(*inode))
            mask |= POLLHUP;
    }

    if (filp->f_mode & FMODE_WRITE) {
        mask |= (nrbufs < PIPE_BUFFERS) ? POLLOUT | POLLWRNORM : 0;
        if (!PIPE_READERS(*inode))
            mask |= POLLERR;
    }

    return mask;
}

我们看到具体的 poll 函数里会首先执行 poll_wait 函数。这个函数只是简单执行 struct ep_pqueue epq 结构体中的函数,即刚才设置的 ep_ptable_queue_proc。

//  监听的文件描述符对应的file结构体,whead是等待监听的文件描述符对应的inode可用的队列
static void ep_ptable_queue_proc(struct file *file, wait_queue_head_t *whead,
                 poll_table *pt)
{
    struct epitem *epi = EP_ITEM_FROM_EPQUEUE(pt);
    struct eppoll_entry *pwq;

    if (epi->nwait >= 0 && (pwq = PWQ_MEM_ALLOC())) {
        pwq->wait->flags = 0;
        pwq->wait->task = NULL;
        // 设置回调
        pwq->wait->func = ep_poll_callback;
        pwq->whead = whead;
        pwq->base = epi;
        // 插入等待监听的文件描述符的inode可用的队列,回调函数是ep_poll_callback
        add_wait_queue(whead, &pwq->wait);
        list_add_tail(&pwq->llink, &epi->pwqlist);
        epi->nwait++;
    } else {
        /* We have to signal that an error occurred */
        epi->nwait = -1;
    }
}

主要的逻辑是把当前进程插入监听的文件的等待队列中,等待唤醒。

epoll_wait
asmlinkage long sys_epoll_wait(int epfd, struct epoll_event __user *events,
                   int maxevents, int timeout)
{
    int error;
    struct file *file;
    struct eventpoll *ep;
    // 通过epoll的fd拿到对应的file结构体
    file = fget(epfd);
    // 通过file结构体拿到eventpoll结构体
    ep = file->private_data;
    error = ep_poll(ep, events, maxevents, timeout);
    return error;
}

static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,
           int maxevents, long timeout)
{
    int res, eavail;
    unsigned long flags;
    long jtimeout;
    wait_queue_t wait;

    // 计算超时时间
    jtimeout = timeout == -1 || timeout > (MAX_SCHEDULE_TIMEOUT - 1000) / HZ ?
        MAX_SCHEDULE_TIMEOUT: (timeout * HZ + 999) / 1000;

retry:

    res = 0;
    // 就绪队列为空
    if (list_empty(&ep->rdllist)) {
        // 加入阻塞队列
        init_waitqueue_entry(&wait, current);
        add_wait_queue(&ep->wq, &wait);

        for (;;) {
            // 挂起
            set_current_state(TASK_INTERRUPTIBLE);
            // 超时或者有就绪事件了,则跳出返回
            if (!list_empty(&ep->rdllist) || !jtimeout)
                break;
            // 被信号唤醒返回EINTR
            if (signal_pending(current)) {
                res = -EINTR;
                break;
            }

            // 设置定时器,然后进程挂起,等待超时唤醒(超时或者信号唤醒)
            jtimeout = schedule_timeout(jtimeout);
        }
        // 移出阻塞队列
        remove_wait_queue(&ep->wq, &wait);
        // 设置就绪
        set_current_state(TASK_RUNNING);
    }

    // 是否有事件就绪,唤醒的原因有几个,被唤醒不代表就有就绪事件
    eavail = !list_empty(&ep->rdllist);

    write_unlock_irqrestore(&ep->lock, flags);
    // 处理就绪事件返回
    if (!res && eavail &&
        !(res = ep_events_transfer(ep, events, maxevents)) && jtimeout)
        goto retry;

    return res;
}

总的来说 epoll_wait 的逻辑主要是处理就绪队列的节点。
1 如果就绪队列为空,则根据 timeout 做下一步处理,可能定时阻塞。
2 如果就绪队列非空则处理就绪队列,返回给用户。处理就绪队列的函数是 ep_events_transfer。

static int ep_events_transfer(struct eventpoll *ep,
                  struct epoll_event __user *events, int maxevents)
{
    int eventcnt = 0;
    struct list_head txlist;

    INIT_LIST_HEAD(&txlist);

    if (ep_collect_ready_items(ep, &txlist, maxevents) > 0) {
        eventcnt = ep_send_events(ep, &txlist, events);
        ep_reinject_items(ep, &txlist);
    }

    return eventcnt;
}

主要是三个函数,我们一个个看。
1 ep_collect_ready_items 收集就绪事件

static int ep_collect_ready_items(struct eventpoll *ep, struct list_head *txlist, int maxevents)
{
    int nepi;
    unsigned long flags;
    // 就绪事件的队列
    struct list_head *lsthead = &ep->rdllist, *lnk;
    struct epitem *epi;

    for (nepi = 0, lnk = lsthead->next; lnk != lsthead && nepi < maxevents;) {
        // 通过结构体字段的地址拿到结构体首地址
        epi = list_entry(lnk, struct epitem, rdllink);

        lnk = lnk->next;

        /* If this file is already in the ready list we exit soon */
        if (!EP_IS_LINKED(&epi->txlink)) {

            epi->revents = epi->event.events;
            // 插入txlist队列,然后处理完再返回给用户
            list_add(&epi->txlink, txlist);
            nepi++;
            // 从就绪队列中删除
            EP_LIST_DEL(&epi->rdllink);
        }
    }

    return nepi;
}

2 ep_send_events 判断哪些事件触发了

static int ep_send_events(struct eventpoll *ep, struct list_head *txlist,
              struct epoll_event __user *events)
{
    int eventcnt = 0;
    unsigned int revents;
    struct list_head *lnk;
    struct epitem *epi;
    // 遍历就绪队列,记录触发的事件
    list_for_each(lnk, txlist) {
        epi = list_entry(lnk, struct epitem, txlink);
        // 判断哪些事件触发了
        revents = epi->ffd.file->f_op->poll(epi->ffd.file, NULL);

        epi->revents = revents & epi->event.events;
        // 复制到用户空间
        if (epi->revents) {
            if (__put_user(epi->revents,
                       &events[eventcnt].events) ||
                __put_user(epi->event.data,
                       &events[eventcnt].data))
                return -EFAULT;
            // 只监听一次,触发完设置成对任何事件都不感兴趣
            if (epi->event.events & EPOLLONESHOT)
                epi->event.events &= EP_PRIVATE_BITS;
            eventcnt++;
        }
    }
    return eventcnt;
}

3 ep_reinject_items 重新插入就绪队列

static void ep_reinject_items(struct eventpoll *ep, struct list_head *txlist)
{
    int ricnt = 0, pwake = 0;
    unsigned long flags;
    struct epitem *epi;

    while (!list_empty(txlist)) {
        epi = list_entry(txlist->next, struct epitem, txlink);
        EP_LIST_DEL(&epi->txlink);
        //  水平触发模式则一直通知,即重新加入就绪队列
        if (EP_RB_LINKED(&epi->rbn) && !(epi->event.events & EPOLLET) &&
            (epi->revents & epi->event.events) && !EP_IS_LINKED(&epi->rdllink)) {
            list_add_tail(&epi->rdllink, &ep->rdllist);
            ricnt++;
        }
    }

}

我们发现,并有没有在 epoll_wait 的时候去收集就绪事件,那么就绪队列是谁处理的呢?我们回顾一下插入红黑树的时候,做了一个事情,就是在文件对应的 inode 上注册一个回调。当文件满足条件的时候,就会唤醒因为 epoll_wait 而阻塞的进程。epoll_wait 会收集事件返回给用户。

static int ep_poll_callback(wait_queue_t *wait, unsigned mode, int sync, void *key)
{
    int pwake = 0;
    unsigned long flags;
    struct epitem *epi = EP_ITEM_FROM_WAIT(wait);
    struct eventpoll *ep = epi->ep;
    // 插入就绪队列
    list_add_tail(&epi->rdllink, &ep->rdllist);
    // 唤醒因epoll_wait而阻塞的进程
    if (waitqueue_active(&ep->wq))
        wake_up(&ep->wq);
    if (waitqueue_active(&ep->poll_wait))
        pwake++;
    return 1;
}

epoll 的实现涉及的内容比较多,先分析一下大致的原理。有机会再深入分析。

发布于 2020-03-28

四、IO 复用中写的问题


文章作者: iKnow
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 iKnow !
  目录