0%

Linux高性能服务器编程

Linux入门

准备工作

安装了远程连接服务器的工具,操作它就不要打开再操作了 sudo yum install openssh-server

查看虚拟机ip地址 ifconfig

登录该虚拟机 ssh kjg@172.16.208.128

利用Vscode远程连接该虚拟机

Gcc编译

什么是GCC

GCC 原名为 GNU C语言编译器(GNU C Compiler)

可以使用命令行选项来控制编译器在翻译源代码时应该遵循哪个C 标准。如,当使用 -std = c99启动 GCC时,编译器支持 C99 标准

GCC命令

安装命令 sudo yum install gcc g++ (版本>4.8.5)

查看版本 gcc / g++ -v / --version

预处理指定的源文件,不进行编译[ .c -> .i ] gcc test.c -E -o test.i

编译指定的源文件,但是不进行汇编[ .i -> .s ] gcc test.i -S -o test.s

编译,汇编指定的源文件,但不进行链接[ .s -> .o ] gcc test.s -c -o test.o

编译生成可执行文件[ .c -> .out ] gcc test.c -o app 如果gcc test.c则生成一个默认的a.out文件

运行 ./app./a.out

GCC工作流程
image-20221026150832523
gcc与g++区别

gcc编译c文件,g++编译c++文件

编译可以用gcc / g++ 链接可用 g++ 或者 gcc -lstdc++

静态库和动态库

什么是库
  • 库是特殊的一种程序,提供给使用者一些可以直接拿来用的变量、函数或类,编写库的程序和编写一般的程序区别不大,只是库不能单独运待。

  • 库文件有两种,静态库和动态库(共享库)区别是:

    静态库在程序的链接阶段被复制到了程序中;

    动态库在链接阶段没有被复制到程序中,而是程序在运行时由系统动态加载到内存中供程序调用。

  • 库的好处: 1.代码保密 2.方便部署和分发

静态库的命名规则

Linux: libxxx.a (lib是固定前缀 a是固定后缀 中间的xxx是库名)

Windows: libxxx.lib

静态库的制作
  1. gcc - c获得.o文件

  2. 将.o文件打包,使用ar工具(archive)

    ar rcs libxxx.a xxx.o xxx.o r是将文件插入备存文件(在这里是库文件)中 c是建立备存文件 s是索引

静态库的使用

得到了一个可用的静态库之后,将其与相应的头文件放到一个目录中(就相当于发布了),然后根据得到的头文件编写测试代码,对静态库中的函数进行调用。

这里我把生成的静态库和相应的头文件放到temp文件夹中进行测试。
这里我们会用到gcc的两个参数

-l 在程序编译的时候,指定使用的库。(静态库的名字一定要掐头去尾。如:libCalc.a变为Calc)
-L 在程序编译的时候,指定使用的库的路径。

如: gcc main.c -o Calc -L ./ -l Calc [第一个Calc是生成的可执行文件名]

运行: ./Calc

动态库的命名规则

Linux: libxxx.so lib:前缀(固定) xxx :库的名字,自己起 so:后缀(固定) 在Linux下是一个可执行文件

Windows: libxxx.dll

动态库的制作

gcc 得到.o文件,得到和位置无关的代码 gcc -c -fpic/-fPIC a.c b.c
gcc 得到动态库 gcc -shared a.o b.o -o libcalc.so

动态库的使用

库文件是src里源文件的定义,头文件是src里源文件的声明;使用时需要将库文件和头文件都分发给src里的文件们

image-20221028142406885
# 需要将使用的库文件拷贝到当前lib目录下
gcc main.c -o main -I include/ -L /lib -l calc
# -I include/ 是为了找到head.h -L /lib是为了找到库文件的目录 -l calc是为了指定库文件名
image-20221028143032281
动态库加载失败的原因
  • 静态库:GCC 进行链接时,会把静态库中代码打包到可执行程序中

  • 动态库:GCC 进行链接时,动态库的代码不会被打包到可执行程序中

  • 程序启动之后,动态库会被动态加载到内存中,通过 ldd 命令检查动态库依赖关系 在上例中就是 ldd main

  • 如何定位共享库文件呢?

    当系统加载可执行代码时候,能够知道其所依赖的库的名字,但是还需要知道绝对路径。此时就需要系统的动态载入器来获取该绝对路径。对于elf格式的可执行程序,是由ld-linux.so来完成的,它先后搜索elf文件的 DT_RPATH段环境变量 LD_LIBRARY_PATH/etc/1d.so.cache文件列表/lib//usr/lib目录找到库文件后将其载入内存。

  • 解决动态库加载失败方式

    1. 配置环境变量[一次性的,临时的]

    image-20221028154045633

    1. .bashrc里配置 同图上同样内容 后输入source .bashrc
    2. /etc/profile里配置 同图上同样内容 后输入source /etc/profile
动静态库优缺点
image-20221028160925281 image-20221028160951754

Makefile

什么是Makefile
  • 一个工程中的源文件不计其数,其按类型、功能、模块分别放在若干个目录中,Makefile 文件定义了一系列的规则来指定哪些文件需要先编译,哪些文件需要后编译,哪些文件需要重新编译,甚至于进行更复杂的功能操作,因为 Makefile 文件就像一个 Shel1 脚本一样,也可以执行操作系统的命令

  • Makefile 带来的好处就是“自动化编译”,一旦写好,只需要一个 make 命令[是Makefile的启动器],整个工程完全自动编译,极大提高了软件开发效率。

  • make 是一个命令工具,是一个解释 Makefile 文件中指令的命令工具,一般来说,大多数的 IDE 都有这个命令,比如 Delphi的make,Visual C++的 nmake,Linux下 GNU的make

Makefile文件命名和规则
image-20221028163708993

例1:

vim Makefile # 不管当前目录有没有Makefile , 只要有你想编译的.c文件就行

================================================
app : add.c sub.c multi.c div.c main.c
gcc add.c sub.c multi.c div.c main.c -o app
================================================

make # sudo yum install make

例2:

如果第一个规则的依赖在当前目录暂时找不到, 那就往后查找看后面的规则的目标是否有满足的

image-20221028172215221

但这样写太繁琐, 如何简化呢? 下面引入变量和模式匹配和函数

变量
自定义变量

变量名= 变量值 var = hello

预定义变量

AR 归档维护程序的名称,默认值为 ar

CC C编译器的名称,默认值为 cc

CXX C++编译器的名称,默认值为 g++

$@ 目标的完整名称

$< 第一个依赖文件的名称

$^ 所有的依赖文件

获取变量的值

$(变量名) 如$(var)

image-20221028174622965

模式匹配
image-20221028174905118
函数
wildcard

获取指定目录下指定类型的文件列表

image-20221028175247985

patsubst
image-20221028175559945 image-20221028180448158

GDB调试

什么是GDB

GDB是由GNU软件系统社区提供的调试工具,同GCC配套组成了一套完整的开发环境,是Linux和许多类Unix系统中的标准开发环境。

一般来说,GDB主要帮助你完成下面四个方面的功能:

  1. 启动程序,可以按照自定义的要求随心所欲的运行程序

  2. 可让被调试的程序在所指定的调置的断点处停住(断点可以是条件表达式)

  3. 当程序被停住时,可以检查此时程序中所发生的事

  4. 可以改变程序,将一个BUG产生的影响修正从而测试其他BUG

准备工作
  • 通常,在为调试而编译时,我们会关掉编译器的优化选项(-o),并打开调试选项(-g)

    另外,-Wall可以在尽量不影响程序行为的情况下选项打开所有warning,也可以发现许多问题,避免一些不必要的BUG。

  • gcc -g -Wall program.c -o program

  • -g选项的作用是在可执行文件中加入源代码的信息,比如可执行文件中第几条机器指令对应源代码的第几行,但并不是把整个源文

    件嵌入到可执行文件中,所以在调试时必须保证gdb能找到源文件。

GDB命令-启动/退出/查看代码
  • 进入gdb环境和退出

gdb 可执行文件

quit

  • 给程序设置参数/获取设置参数[需要先进入gdb环境]

set args 10 20

show args

  • GDB使用帮助 直接help 或者set(还可以是其他的关键字) help

  • 查看当前文件代码[vim 文件名]

list/l (从默认位置 显示 前面必须有-g)

list/l 行号 (从指定的行显示 前面必须有-g)

list/l 函数名 (从指定的函数显示 前面必须有-g)

  • 查看非当前文件代码

list/l 文件名:行号

list/l 文件名:函数名

  • 设置/显示行数

show list/listsize 显示行数
set list/listsize 行数 设置行数

GDB命令-断点操作
  • 设置断点

b/break 行号

b/break 函数名

b/break 文件名:行号

b/break 文件名:函数

  • 查看断点

i/info b/break

image-20221029160442047
  • 删除断点

d/del/delete 断点编号

  • 设置断点无效

dis/disable 断点编号

  • 设置断点生效

ena/enable 断点编号

  • 设置条件断点(一般用在循环的位置)

b/break 10 if i=5 在第十行设置断点

GDB命令-调试命令
  • 运行GDB程序

start (程序停在第一行)

run (遇到断点才停)

  • 继续运行,到下一个断点停

c/ continue

  • 向下执行一行代码(不会进入函数体)

n/ next

  • 向下执行一行代码((遇到函数进入函数体)

s/ step

finish (跳出函数体)

  • 变量操作

p/print 变量名 (打印变量值)

ptype 变量名 (打印变量类型)

  • 自动变量操作

display a; display b; (每次调试时(输入n, s), 如果a, b值发生变化的话, 自动打印指定变量(在这里是a, b)的值)

i/info display 查看设置了哪些自动变量

undisplay 编号

  • 其它操作

set var 变量名 = 变量值

until (跳出循环)

Linux文件IO

  • 文件角度: 输入: 内存 -> 文件 输出: 文件 -> 内存

  • 内存角度: 输入: 文件 -> 内存 输出: 内存 -> 文件 【我们通常站在内存角度】

标准C库文件函数

标准C库函数是带缓冲区的, Linux文件操作时可选用C库函数,效率更好。 网络通信时使用Linux自己的库函数,效率更好

C语言写出的程序可以跨平台运行的原因是C库函数继续调用各系统的库函数

image-20221029165648092
标准C库IO和LinuxIO的区别

即 C库IO 调用 LinuxIO

image-20221029171653493

虚拟地址空间

  • 它使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间)

  • 一个进程一个虚拟地址空间,该空间会被MMU映射到真实的内存中(以32位机器举例)

  • 大多数操作系统都使用了虚拟内存,如Windows家族的“虚拟内存”;Linux的“交换空间”等

  • 内核区可以通过调用Linux的API访问

image-20221029172431281

文件描述符

负责 索引到对应的磁盘文件

image-20221030145918168

LInux系统函数

帮助文档 :

man 2 关键词

关键词 --help

errno & perror

errno 记录错误号

void perror(const string *s) 打印错误描述

open & close

int open (const char *pathname, int flags); 打开一个已经存在的文件, 返回一个新的文件描述符 FILE *

pathname 要打开的文件路径

flags 对文件的操作权限设置 有三个: O_RDONLY O_WRONLY``O_RDWR

使用: int fd = open("a.txt", O_RDONLY);

int open (const char *pathname, int flags, mode_t mode); 创建一个新的文件

flags 对文件的操作权限设置 有三个: O_RDONLY O_WRONLY _RDWR 这里多一个可选项 O_CREAT: 文件不存在, 创建新文件

mode: 八进制权限码,一定是在flags中使用了O_CREAT标志,mode记录待创建的文件的访问权限

fd = open("./file1", O_RDWR|O_CREAT, 0600);

int close (int fd);

read

ssize_t read(int fd, void *buf, size_t count);

fd 为文件描述符;buf 表示读出数据缓冲区地址;count 表示要读出的字节数。

返回值:若读取成功,则返回读到的字节数;若失败,返回-1;若已达到文件尾,则返回0。因此读到的字节数可能小于count的值

write

ssize_t write (int fd, const void *buf, size_t count);

lseek

在程序中,在调用read函数之前,先调用了close函数和open函数,这是为了让光标移到文件的头,否则将读取失败。因此,就还需要用到lseek函数来移动文件中光标的位置。通过调用lseek函数可以改变光标的位置,其函数原型为

off_t lseek(int fd, off_t offset, int whence);

其中,fd为文件描述符;offset指的是每一次读写操作所需移动距离,以字节为单位 ,可正可负,正值表示想文件尾部移动,负值表示向文件头部移动。whence表示当前位置的基点,主要有以下三个基点符号常量。

SEEK_SEK 将光标移到距离文件头前后offset个字节;

SEEK_CUR 将光标移到当前位置前后offset个字节;

SEEK_END 将光标移到文件末尾前后offset个字节。

  • 除此之外,lseek函数还可以用来计算文件大小,因为他的返回值是以字节为单位,从文件的起始点开始计算到当前位置的字节数
  • int size_of_file = lseek(fd, 0, SEEK_END);
stat & lstat

stat命令用于显示文件的状态信息。stat命令的输出信息比ls命令的输出信息要更详细

stat int stat(const char *pathname, struct stat *buf); (使用时要先struct stat st; 传入&st)

lstat int lstat(const char *pathname, struct stat buf);

stat 获取链接文件的信息时,具有穿透能力,直接穿越链接文件,获取所被链接文件的信息。

lstat 获取链接文件的信息,无穿透能力

st.st_mode 获取到的部分文件信息用st.st_mode 与下面这13个码分别相与得到,第一个&得出4位,后面的每个&得出1位

st.st_xxx 其他的所有属性也在这里面,到时查手册即可

image-20221030193146766

Linux文件属性操作函数

access

int access(const char* pathname, int mode);

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

mode:指定access的作用,取值如下

  1. F_OK 值为0,判断文件是否存在
  2. X_OK 值为1,判断对文件是可执行权限
  3. W_OK 值为2,判断对文件是否有写权限
  4. R_OK 值为4,判断对文件是否有读权限

注:后三种可以使用或“|”的方式,一起使用,如W_OK|R_OK

chmod

int chmod(const char* filename ,int mode);

chmod [-cfvR] [--help] [--version] mode file 其中mode格式 : [ugoa] [+ - =] [rwxX] [ ,... ]

u 表示该档案的拥有者,g表示与该档案的拥有者属于同一个群体(group)者,o 表示其他以外的人,a 表示这三者皆是。

+表示增加权限、- 表示取消权限、= 表示唯一设定权限。

r 表示可读取,w 表示可写入,x 表示可执行,X 表示只有当该档案是个子目录或者该档案已经被设定过为可执行。

-c 若该档案权限确实已经更改,才显示其更改动作

-f 若该档案权限无法被更改也不要显示错误讯息

-v 显示权限变更的详细资料

-R 对目前目录下的所有档案与子目录进行相同的权限变更(即以递回的方式逐个变更)

–help 显示辅助说明

–version 显示版本

chmod ugo+r file1.txt 将档案 file1.txt 设为所有人皆可读取 :

chmod a+r file1.txt 将档案 file1.txt 设为所有人皆可读取 :

chmod ug+w,o-w file1.txt file2.txt 将档案 file1.txt 与 file2.txt 设为该档案拥有者与其同组可写入,其他人不可写入

chmod u+x ex1.py 将 ex1.py 设定为只有该档案拥有者可以执行

chmod -R a+r * 将目前目录下的所有档案与子目录皆设为任何人可读取

chmod ug=rwx,o=x file 设置该档案拥有者与其同组可读写执行,其他人只能执行

chown

int chown(const char *path, uid_t owner, gid_t group);

chown [参数] user[:group] [文件]

参数 参数说明
user 新的文件拥有者的使用者 ID
group 新的文件拥有者的使用者组(group)
-c 显示更改的部分的信息
-f 忽略错误信息
-h -h 改变的是链接文件属主, 不加-h改变的是链接源文件属主
-v 显示详细的处理信息
-R 处理指定目录以及其子目录下的所有文件
–help 显示辅助说明
–version 显示版本

以下选项修改了在还指定了-R选项时遍历层次结构的方式。如果指定了多个,则只有最后一个生效。

-H 如果命令行参数是指向目录的符号链接,则遍历它

-L 遍历遇到的每个指向目录的符号链接

-P 不遍历任何符号链接(默认)

truncate

int truncate(const char *path, off_t length);

truncate OPTION... FILE... 命令可以将一个文件缩小或者扩展到某个给定的大小.可以用-s选项来指定文件的大小

-c do not create any files

-o treat SIZE as number of IO blocks instead of bytes

-r base size on RFILE

-s set or adjust the file size by SIZE bytes

–help

–version

Linux目录操作函数

mkdir

int mkdir (const char *pathname, mode_t mode) ;

rmdir

int rmdir (const char *pathname) ;

rename

int rename (const char *oldname, const char* newname) ;

getcwd

char *getcwd (char *buf,size_t size) ;

getcwd 会将当前工作目录的绝对路径复制到参数buf所指的内存空间中,参数size为buf的空间大小。

opendir

DIR* opendir (const char* name) ;

打开一个目录并建立一个目录流

如果打开成功的话返回一个DIR结构的指针,该指针用于读取目录数据项。

如果失败的话返回一个空指针如果文件中的文件过多也可能打开失败

chdir

int chdir (const char* path) ;

改变当前工作目录

readir

struct dirent* readdir (DIR* dirp) ;

返回一个指向 struct dirent 结构体的指针,该结构体表示 dirp 指向的目录流中的下一个目录条目。在到达目录流的末尾或发生错误时,它返回 NULL。

struct dirent {
ino_t d_ino; /* inode 编号 */
off_t d_off; /* not an offset; see NOTES */
unsigned short d_reclen; /* length of this record */
unsigned char d_type; /* type of file; not supported by all filesystem types */
char d_name[256]; /* 文件名 */
};
closedir

int closedir (DIR* dirp) ;

dup & dup2

int dup(int oldfd) ;

在 Linux 系统中, open 返回得到的文件描述符 fd 可以进行复制,复制成功之后可以得到一个新文件描述符,使用新的文件描述符和旧的文件描述符都可以对文件进行 IO 操作,复制得到的文件描述符和旧的文件描述符拥有相同的权限,譬如使用旧的文件描述符对文件有读写权限,那么新的文件描述符同样也具 有读写权限;在 Linux 系统下,可以使用 dup 或 dup2 这两个系统调用对文件描述符进行复制。我们来学习下两个函数的用法以及它们之间的区别。

复制得到的文件描述符与旧的文件描述符都指向了同一个文件表,假设 fd1 为原文件描述符, fd2 为复制得到的文件描述符,如下图所示:

img

因为复制得到的文件描述符与旧的文件描述符指向的是同一个文件表,所以可知,这两个文件描述符的 属性是一样,譬如对文件的读写权限、文件状态标志、文件偏移量等,所以从这里也可知道“复制”的含义实则是复制文件表。同样,在使用完毕之后也需要使用 close 来关闭文件描述符。

例子:若fd 等于 3 ,复制得到的新的文件描述符为可能为 5

int dup2(int oldfd, int newfd);

oldfd : 需要被复制的文件描述符。
newfd : 指定一个文件描述符(需要指定一个当前进程没有使用到的文件描述符)。
返回值: 成功时将返回一个新的文件描述符,即手动指定的文件描述符 newfd ;如复制失败将返回-1 ,并且会设置 errno 值。

fcntl

int fcntl(int fd, int cmd);

int fcntl(int fd, int cmd, long arg);

int fcntl(int fd, int cmd ,struct flock* lock);

fcntl系统调用可以用来对已打开的文件描述符进行各种控制操作以改变已打开文件的的各种属性

执行失败返回-1 否则返回>0

cmd参数对应功能如下:

img

记录锁:实现只锁文件的某个部分,并且可以灵活的选择是阻塞方式还是立刻返回方式

当fcntl用于管理文件记录锁的操作时,第三个参数指向一个struct flock *lock的结构体

struct flock {
short_l_type; /*锁的类型*/
short_l_whence; /*偏移量的起始位置:SEEK_SET,SEEK_CUR,SEEK_END*/
off_t_l_start; /*加锁的起始偏移*/
off_t_l_len; /*上锁字节*/
pid_t_l_pid; /*锁的属主进程ID */
};

Linux 多进程开发

程序和进程概述

PCB

为了管理进程,内核必须对每个进程所做的事情进行清楚的描述。内核为每个进程分配一个PCB (Processing Control Block) 进程控制块,维护进程相关的信息。

Linux内核的进程控制块是task_struct 结构体。在/usr/src/linux-headers-xxx/include/linux/sched.h文件中可以查看其定义

其内部成员有很多,我们只需要掌握以下部分即可:

进程id:系统中每个进程有唯一的id, 用pid_t类型表示, 其实就是一个非负整数

进程的状态:有就绪、运行、挂起、停止等状态

进程切换时需要保存和恢复的一些CPU寄存器

描述虚拟地址空间的信息

描述控制终端的信息

当前工作目录(Current Working Directory)

umask掩杩

文件描述符表, 包含很多指向file结构体的指针**(一个进程一个虚拟地址空间,该虚拟空间内核区有个PCB, PCB里有文件描述符表)**

和信号相关的信息

用户id和组id

会话 (Session) 和 进程组

进程可以使用的资源上限(Resource Limit)

五状态模型
image-20221031215037224

进程相关命令

tty

tty 显示当前终端

ulimit

ulimit -a 查看系统中所有资源使用情况

ps

ps -aux /ajx 查看进程

a: 显示终端上的所有进程,包括其他用户的进程
u: 显示进程的详细信息
x: 显示没有控制终端的进程
j: 列出与作业控制相关的信息

top

top 实时显示进程动态

可以在使用top命令时加上-d来指定显示信息更新的时间间隔,在top命令执行后,可以按以下按键对显示的结果进行排序:

M 根据内存使用量排序
P 根据CPU占有率排序
T 根据进程运行时间长短排序
U 根据用户名来筛选进程.
K 输入指定的PID杀死进程

kill

kill [-选项] pid 杀死进程

kill -l 列出所有可选选项

-9 强制杀死进程

获取pid函数

pid_t getpid (void); 获取当前进程号

pid_t getppid (void); 获取父进程号

pid_t getpgid(pid_t pid); 获取组进程号

进程组是一个或多个进程的集合。他们之间相互关联,进程组可以接收同一终端的各种信号,关联的进程有一个进程组号(PGID) 。默认情况下,当前的进程号会当做当前的进程组号。

fork函数

pid_t fork(void); 系统允许一个进程创建新进程,新进程即为子进程,子进程还可以创建新的子进程,形成进程树结构模型。

成功:子进程中返回 0,父进程中返回子进程 ID 失败:返回 -1

失败的两个主要原因:

1.当前系统的进程数已经达到了系统规定的上限,这时 errno 的值被设置为 EAGAIN

2.系统内存不足,这时 errno 的值被设置为 ENOMEM

父子进程虚拟地址空间

  1. 内核区的pid(自己的)不同
  2. 使用pid_t a = fork()后,栈内的返回值不同,父进程栈里a为子进程号, 子进程栈里a为0
  3. 修改栈空间的变量互不干扰
  4. 父子进程有相同的文件描述符,指向相同的文件表,引用计数增加,共享文件偏移指针
image-20221101013945568

GDB多进程调试

使用GDB调试的时候,GDB 默认只能跟踪一个进程, 可以在fork 函数调用之前,通过指令设置GDB调试工具跟踪父进程或者是跟踪子进程,默认跟踪父进程。

set follow-fork-mode [parent (默认) | child]

设置调试父进程或者子进程[被调试的进程停在断点处, 另一个进程顺利执行]

set detach-on-fork [on| off]

设置调试模式默认为on,表示调试当前进程的时候,其它的进程继续运行,如果为off, 调试当前进程的时候,其它进程被GDB挂起。

info inferiors 查看调试的进程

inferior id 切换当前调试的进程

detach inferiors id 使进程脱离GDB调试

exec函数族

exec 函数族的作用是根据指定的文件名找到可执行文件,并用它来取代调用进程的内容,换句话说,就是在调用进程内部执行一个可执行文件。

exec函数族的函数执行成功后不会返回,因为调用进程的实体,包括代码段,数据段和堆栈等都已经被新的内容取代,只留下进程ID等一些表面上的信息仍保持原样,颇有些神似“三十六计”中的“金蝉脱壳”。只有调用失败了,它们才会返回-1, 从原程序的调用点接着往下执行。

exec函数族共有6种不同形式的函数。这6个函数可以划分为两组:

(1)execl、execle和execlp。

这里的l是list(列表)的意思,表示execl系列函数需要将每个命令行参数作为函数的参数进行传递;

(2)execv、execve和execvp。

而v是vector(矢量)的意思,表示execv系列函数将所有函数包装到一个矢量数组中传递即可

int execl(const char * path,const char * arg,…);

int execle(const char * path,const char * arg,char * const envp[]);

int execlp(const char * file,const char * arg,…);

int execv(const char * path,char * const argv[]);

int execve(const char * path,char * const argv[],char * const envp[]);

int execvp(const char * file,char * const argv[]);

path 要执行的程序路径。可以是绝对路径或者是相对路径。在execv、execve、execl和execle这4个函数中,使用带路径名的文件名作为参数。

file 要执行的程序名称。如果该参数中包含“/”字符,则视为路径名直接执行;否则视为单独的文件名,系统将根据PATH环境变量指定的路径顺序搜索指定的文件。

argv 命令行参数的矢量数组。

envp 带有该参数的exec函数可以在调用时指定一个环境变量数组。其他不带该参数的exec函数则使用调用进程的环境变量。

arg 程序的第0个参数,即程序名自身。相当于argv[0]。

… 命令行参数列表。调用相应程序时有多少命令行参数,就需要有多少个输入参数项。注意:在使用此类函数时,在所有命令行参数的最后应该增加一个空的参数项(NULL),表明命令行参数结束。

结束进程、孤儿进程、僵尸进程

结束进程

exit是标准C库函数

_exit是标准Linux库函数

status是进程退出是的一个状态信息,父进程回收子进程资源时可以获取到

image-20221101143024126 image-20221101143922053
孤儿进程
  • **父进程运行结束,但子进程还在运行(未运行结束)**,这样的子进程就称为孤儿进程(Orphan Process) 。

  • 每当出现一个孤儿进程的时候,内核就把孤儿进程的父进程设置为init ,而init进程会循环地wait() 它的已经退出的子进程。当一个孤儿进程凄凉地结束了其生命周期的时候,init 进程就会代表党和政府出面处理它的一切善后工作。因此孤儿进程并不会有什么危害。

僵尸进程
  • 每个进程结束之后,都会释放自己地址空间中的用户区数据,内核区的PCB没有办法自己释放掉,需要父进程去释放。

  • 进程终止时,父进程尚未回收它,子进程残留资源(PCB) 存放于内核中,变成僵尸(Zombie)进程

  • 僵尸进程不能被kill -9杀死。

  • 这样就会导致一个问题,如果父进程不调用wait() 或waitpid() 的话,那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵尸进程,将因为没有可用的进程号而导致系统不能产生新的进程,此即为僵尸进程的危害,应当避免。

kill -9 僵尸进程号 杀不了僵尸进程, 只有把他父进程杀了/在父进程中按ctrl + c/调用wait()waitpid()函数后, 他才会被杀死

wait、waitpid函数

在每个进程退出的时候,内核释放该进程所有的资源、包括打开的文件、占用的内存等。但是仍然为其保留一定的信息,这些信息主要主

要指进程控制块PCB的信息(包括进程号、退出状态、运行时间等)。

父进程可以通过调用wait或waitpid得到它的退出状态同时彻底清除掉这个进程。wait()和waitpid() 函数的功能一样

区别在于,wait()函数会阻塞 waitpid()可以设置不阻塞,waitpid() 还可以指定等待哪个子进程结束

注意: 一次wait或waitpid调用只能清理一个子进程,清理多个子进程应使用循环。

wait

pid_t wait(int *stat_loc);

返回值:如果执行成功则返回子进程识别码(PID), 如果有错误发生则返回-1. 失败原因存于errno 中

stat_loc 可以是a, b, c 然后调用WIFEXITED(a) WEXITSTATUS(b) WIFSIGNALED(stat_val) WTERMSIG(stat_val) WIFSTOPPED(stat_val) WSTOPSIG(stat_val) WIFCONTINUED(stat_val)能获取各种返回状态

waitpid

pid_t waitpid(pid_t pid, int *stat_loc, int options);

pid == -1 等待任一子进程。与wait等效

pid > 0 等待其进程ID 与 pid 相等的子进程

pid == 0 等待进程组ID 与 目前进程相同的任何子进程。(少用/基本不用)

pid < -1 等待其组ID 等于 pid的绝对值的任一子进程。(少用/基本不用)

options == 0,表示waitpid函数为阻塞的。(该函数会阻塞卡在这儿,若有子进程,就回收;若没有子进程,一直卡着)

options == WNOHANG,表示waitpid函数为非阻塞的(也即不管有没有子进程了,该函数都不会阻塞卡在这儿)

进程间通信简介

进程是一个独立的资源分配单元,不同进程(这里所说的进程通常指的是用户进程)之间的资源是独立的,没有关联,不能在一个进程中直接访问另一个进程的资源。

但进程不是孤立的,不同的进程需要进行信息的交互和状态的传递等,因此需要进程间通信( IPC: Inter Processes Communication )。

进程通信的目的:

数据传输 一个进程需要将它的数据发送给另一个进程。

通知事件 一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)

资源共享 多个进程之间共享同样的资源。为了做到这一点,需要内核提供互斥和同步机制。

进程控制 有些进程希望完全控制另一个进程的执行(如Debug 进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。

* Linux进程通信方式
image-20230125202337973

匿名管道[管道]

用在父子或兄弟进程中

image-20221102140508146

管道的特点
  • 管道其实是一个在内核内存中维护的缓冲器,这个缓冲器的存储能力是有限的,不同的操作系统大小不一定相同。

  • 管道拥有文件的特质:读操作、写操作,匿名管道没有文件实体,有名管道有文件实体,但不存储数据。可以按照操作文件的方式对管道进行操作。

  • 一个管道是一个字节流,使用管道时不存在消息或者消息边界的概念,从管道读取数据的进程可以读取任意大小的数据块,而不管写入进程写入管道的数据块的大小是多少。

  • 通过管道传递的数据是顺序的,从管道中读取出来的字节的顺序和它们被写入管道的顺序是完全一样的。

image-20221102143252014
匿名管道进程间通信原理
image-20221102144326483
匿名管道的数据结构
  • 逻辑环形队列
image-20221102144456528
匿名管道的使用

使用 xxx | xxx

创建一个匿名管道

int pipe(int pipefd[2]);

pipefd 数组是一个传出参数 pipefd[0] 对应管道的读端 pipefd[1] 对应管道的写端

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


int main(){
//在fork之前创建管道
int pipefd[2];
int ret = pipe(pipefd);

if(ret == -1){
perror(" pipe" );
exit(0);
}

// 创建子进程
pid_t pid = fork();

if(pid > 0){
char buf[1024] = {0};
while(1){
//父进程, 读
// 从管道的读取端读取数据, 返回读取到的字节数 若管道内没有数据自动阻塞
int len = read(pipefd[0], buf, sizeof(buf));
printf("parent process recv: %s, pid: %d", buf, getpid());
bzero(buf, 1024); // 将buf置为全0

//父进程, 写
char* str = "hello,i am a parent";
write(pipefd[1], str, strlen(str));
sleep(1);
}
}
else if(pid == 0){
char buf[1024] = {0};
while(1){
//子进程, 写
char* str = "hello,i am a child";
write(pipefd[1], str, strlen(str));
sleep (1); // 若没有sleep 则下面读模块会读取自己刚写的

// 子进程, 读 一定要在子进程写后 不然父子进程都阻塞
int len = read(pipefd[0], buf, sizeof(buf));
printf("child process recv: %s, pid: %d", buf, getpid());
bzero(buf, 1024);
}
}
return 0;
}
查看管道缓冲大小

ulimit -a 命令

long fpathconf (int fd, int name); 函数

image-20221102174749178
设置管道非阻塞
int flags = fcntl(fd[0], F_GETFL);		//获取原来的flag
flags |= O_NONBLOCK; //修改flag的值
fcntl(fd[0], F_SETFL, flags); //设置新的flag

有名管道

用在没有关系的进程中

  • 匿名管道,只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了有名管道(FIFO) ,也叫命名管道、FIF0文件。
  • 有名管道(FIFO) 不同于匿名管道在于它提供了一个路径名与之关联,以FIFO 的文件形式存在于文件系统中,并且其打开方式与打开一个普通文件是一样的,这样即使与FIFO的创建进程不存在亲缘关系的进程,只要可以访问该路径,就能够彼此通过FIFO相互通信,因此,通过FIFO不相关的进程也能交换数据。
  • 一旦打开了FIFO,就能在它上面使用与操作匿名管道和其他文件的系统调用一样的I/O系统调用了(如read()、 write ()和close())。与管道一样,FIFO也有一个写入端和读取端,并且从管道中读取数据的顺序与写入的顺序是一样的。FIFO的名称也由此而来:先入先出。
  • 有名管道(FIFO)和匿名管道(pipe) 有一些特点是相同的,不一样的地方在于:
    1. FIFO 在文件系统中作为一个特殊文件存在,但FIFO 中的内容却存放在内存中。
    2. 当使用FIFO的进程退出后,FIFO文件将继续保存在文件系统中以便以后使用。
    3. FIFO有名字,不相关的进程可以通过打开有名管道进行通信。
创建有名管道

mkfifo 名字 通过函数创建有名管道

int mkfifo(const char *pathname, mode_t mode); 通过函数创建有名管道

mode 和 open 函数的mode是一样的

一旦使用mkfifo 创建了一个FIFO, 就可以使用open打开,常见的文件I/0函数都可用于fifo,如 close、read、 write、unlink

FIFO严格遵循先进先出(First in First out),对管道及FIFO 的读总是从开始处返回数据,对它们的写则把数据添加到末尾。它们不支持诸如lseek()等文件定位操作。

使用有名管道
  • 创建两个文件(两个进程)
//向管道中写数据
#include <sys/types.h>
#include <sys/stat.h>
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include <unistd.h>
#include<fcntl.h>
/*
1.一个为只读打开管道进程会阻塞,直到写端打开
2.一个为只写打开管道进程会阻塞,直到读端打开
*/
int main(){
//1、先判断文件是否存在
int ret = access("test", F_OK);
if(ret == -1){
perror("管道不存在,创建管道\n");
// 2、创建管道文件
ret = mkfifo("test", 0664);
if(ret == -1){
perror("mkfifo");
exit(0);
}
}
//3、打开管道,以只写的方式
int fd = open("test", O_WRONLY);
if(fd == -1){
perror("open");
exit(0);
}
//4、写数据
for(int i = 0; i < 100; i++){
char buf[1024];
sprintf(buf, "hello, %d\n ", i); // sprintf函数调用的主要用途就是把一个字符串放在一个已知的字符数组里去
printf("write data: %s\n", buf);
write(fd, buf, strlen(buf));
sleep(1);
}
close(fd);
return 0;
}
// 从管道中读数据
#include <sys/types.h>
#include <sys/stat.h>
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include <unistd.h>
#include<fcntl.h>

int main(){
//1、打开管道文件
int fd=open("test",O_RDONLY);
if(fd == -1){
perror("open");
exit(0);
}
//2、读数据
while(1){
char buf[1024] = {0};
int len = read(fd, buf, sizeof(buf));
if(len == 0){
printf("写端断开连接...\n");
break;
}
printf("recv buf: %s\n",buf);
}
close(fd);
return 0;
}
// 测试 : 需要两个进程(这里是两个文件)同时执行
有名管道实现聊天功能
  • 这样的文件 (进程) 有两份 A文件中为以下代码,B文件与A中只读和只写的有名管道相反
#include<stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include<stdlib.h>
#include<fcntl.h>
#include<string.h>
int main(){
//1、判断有名管道文件是否存在
int ret = access("fifo1", F_OK);
if(ret == -1){
//文件不存在
printf("管道不存在,创建对应的有名管道\n");
//创建管道
ret = mkfifo("fifo1", 0664);
if (ret == -1){
perror("mkfifo");
exit(0);
}
}

//判断管道2是否存在
ret=access("fifo2", F_OK);
if(ret == -1){
//文件不存在
printf("管道不存在,创建对应的有名管道\n");
ret = mkfifo("fifo2", 0664);
if (ret ==- 1){
perror("mkfifo");
exit(0);
}
}

//以只写的方式打开fifo1
int fdw = open("fifo1", O_WRONLY);
if(fdw == -1){
perror("open");
exit(0);
}
printf("打开管道1fifo1成功, 等待写入数据\n");

//以只读的方式打开fifo2
int fdr = open("fifo2",O_RDONLY);
if(fdr == -1){
perror("open");
exit(0);
}
printf("打开管道fifo2成功, 等待读取...\n");

char buf[128];
//4、循环读取数据
while(1){
memset(buf, 0, 128);
//读取标准输入的数据(test用户输入的东西)
fgets(buf,128,stdin);
//写数据
ret = write(fdw, buf, strlen(buf));
if(ret==-1){
perror("write");
exit(0);
}

//5、读管道数据
mset(buf,0,128);//清空数据
ret = read(fdr, buf, 128);
if(ret <= 0){
perror("read");
break;
}
printf("buf:%s\n",buf);
}

//6、关闭文件描述符
close(fdr);
close(fdw);
return 0;
}
// 测试

// 终端1
gcc chatA.c -o a
gcc chatB.c -o b
./a
// 开始聊天...

// 终端2
./b
// 开始聊天...

使用管道的四种特殊情况

关闭读描述符 close(piped[0]); 写类似

  • 没有进程写,只有进程读 如果所有指向管道写端的文件描述符都关闭了,而仍然有进程从管道的读端读数据,那么文件内的所有内容被读完后再次read就会返回0,就像读到文件结尾。
  • 写描述符没关,但也没写 如果有指向管道写端的文件描述符没有关闭(管道写段的引用计数大于0),而持有管道写端的进程没有向管道内写入数据,假如这时有进程从管道读端读数据,那么读完管道内剩余的数据后就会阻塞等待,直到有数据可读才读取数据并返回。
  • 没有进程读,只有进程写 如果所有指向管道读端的文件描述符都关闭,此时有进程通过写端文件描述符向管道内写数据时,则该进程就会收到SIGPIPE信号,并异常终止。
  • 读描述符没关,但也没读 如果有指向管道读端的文件描述符没有关闭(管道读端的引用计数大于0),而持有管道读端的进程没有从管道内读数据,假如此时有进程通过管道写段写数据,那么管道被写满后就会被阻塞,直到管道内有空位置后才写入数据并返回。
image-20221102194024601

内存映射

  • 内存映射区通信,是非阻塞。

  • 进程间有无关系都可以

image-20221103120849314
内存映射的相关系统调用

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset); 内存映射

addr 是要映射的内存的初始地址,一般由内核指定,我们写 NULL 就行

length 要映射的数据的长度,这个值不能为0 (一般为分页的整数倍),一般使用文件的长度 ⬇️

// 法1
int size_of_file = lseek(fd, 0, SEEK_END);

// 法2
struct stat st;
stat(_pName, &st);
return st.st_size;

prot 对申请的内存映射区的操作权限 [不能只指定写权限]

PROT_READ      Data can be read.
PROT_WRITE Data can be written.
PROT_EXEC Data can be executed.
PROT_NONE Data cannot be accessed.

flags

MAP_SHARED          Changes are shared. 内存映射区的数据会自动和磁盘文件进行同步,进程间通信必须要设置这个选项
MAP_PRIVATE Changes are private. 内存映射区的数据改变了,不会修改原来磁盘的文件,会创建一个新文件
MAP_FIXED Interpret addr exactly.

fd 需要映射的文件的文件描述符(通过open函数得到(PROT权限要小于open的权限))

offset 偏移量,一般不用。必须是4K的整数倍,0表示不偏移

返回值:返回要创建的内存的首地址,失败返回MAP_FAILED宏

int munmap(void * addr, size_t length) ; 解除内存映射

addr 要释放的内存的首地址

length 要释放的内存的大小,要和 mmap 中的 length 一样

使用内存映射实现进程间通信
1.有关系的进程(父子进程)
- 还没有子进程的时候
- 通过唯一的父进程,先创建内存映射区
- 有了内存映射区以后,创建子进程
- 父子进程共享创建的内存映射区

2.没有关系的进程间通信
- 准备一个大小不是0的磁盘文件
- 进程1 通过磁盘文件创建内存映射区
- 得到一个操作这块内存的指针
- 进程2 通过磁盘文件创建内存映射区
- 得到一个操作这块内存的指针
- 使用内存映射区通信
// 创建一个 test.txt 在里面写一点数据

// 父子进程间通讯(两个独立的进程通信:将下面的代码拆成两个文件 需要各打开同一个文件,都得创建内存映射区)
#include <stdio.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <sys/types.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <wait.h>

int main() {
// 1.打开一个文件
int fd = open("test.txt", O_RDWR);
int size = lseek(fd, 0, SEEK_END); // 获取文件的大小

// 2.创建内存映射区
void *ptr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if(ptr == MAP_FAILED) {
perror("mmap");
exit(0);
}

// 3.创建子进程
pid_t pid = fork();
if(pid > 0) { // 父进程
wait(NULL); // 子进程往文件中写数据,写完了后父进程就将它回收,再读
char buf[64];
strcpy(buf, (char *)ptr); // 把 ptr 从 void * 强转为 char *
printf("read data : %s\n", buf);

}else if(pid == 0){ // 子进程
strcpy((char *)ptr, "nihao a, son!!!"); // 把 ptr 从 void * 强转为 char *
}

// 关闭内存映射区
munmap(ptr, size);
return 0;
}
内存映射TIPS

如果对mmap的返回值(ptr)做++操作(ptr++),munmap是否能够成功?

void * ptr = mmap(...);
ptr++; //可以对其进行++操作,但是不建议这样做,会导致无法正确释放映射
munmap(ptr, len); // 错误,要保存地址

如果open时O_RDONLY, mmap 时 prot 参数指定PROT_READ | PROT_WRITE会怎样?

错误,返回 MAP_FAILED
open()函数中的权限建议和 prot 参数的权限保持一致。

如果文件偏移量为1000会怎样?

偏移量必须是 4K 的整数倍,返回 MAP_FAILED。

可以open的时候O_CREAT一个新文件来创建映射区吗?

- 可以的,但是创建的文件的大小如果为0的话,肯定不行
- 可以对新的文件进行扩展
- lseek()
- truncate()

mmap后关闭文件描述符,对mmap映射有没有影响?

int fd = open("XXX");
mmap(,,,,fd,0);
close(fd); // 映射区还存在,创建映射区的fd被关闭,没有任何影响。

信号

信号的概念
  • 信号是Linux进程间通信的最古老的方式之一,是事件发生时对进程的通知机制,有时也称之为软件中断,它是在软件层次上对中断机制的一种模拟,是一种异步通信的方式。信号可以导致一个正在运行的进程被另一个正在运行的异步进程中断,转而处理某一个突发事件

  • 发往进程的诸多信号,通常都是源于内核。引发内核为进程产生信号的各类事件如下:

    1. 对于前台进程,用户可以通过输入特殊的终端字符来给它发送信号。比如输入Ctrl+C通常会给进程发送一个中断信号。
    2. 硬件发生异常,即硬件检测到一个错误条件并通知内核,随即再由内核发送相应信号给相关进程。比如执行一条异常的机器语言指令,诸如被0除,或者引用了无法访问的内存区域。
    3. 系统状态变化,比如 alarm 定时器到期将引起SIGALRM 信号,进程执行的CPU时间超限,或者该进程的某个子进程退出。
    4. 运行 kill 命令或调用kill 函数。
  • 使用信号的两个主要目的是:

    1. 让进程知道已经发生了一个特定的事情。
    2. 强迫进程执行它自己代码中的信号处理程序。
  • 信号的特点:

    1. 简单
    2. 不能携带大量信息
    3. 满足某个特定条件才发送
    4. 优先级比较高
  • 查看系统定义的信号列表 kill -l (前31个信号为常规信号,其余为实时信号)

部分信号一览表
image-20221103164530935 image-20221103165336300 image-20221103165127529 image-20221103165504053
举例:SIGCHILD

image-20221104170616515

信号的五种默认处理动作

查看信号的详细信息 man 7 signal

信号的5种默认处理动作:

  1. Term 终止进程
  2. Ign 当前进程忽略掉这个信号
  3. Core 终止进程,并生成一个Core文件
  4. Stop 暂停当前进程
  5. Cont 继续执行当前被暂停的进程

信号的几种状态: 产生、未决(没到达进程)、递达(到达进程)

SIGKILL 和 SIGSTOP 信号不能被捕捉、阻塞或者忽略,只能执行默认动作。

信号相关函数
#include <sys/types.h>
#include <signal.h>
kill

int kill (pid_t pid, int sig); 给任何进程或者进程组pid发送任何信号sig

pid:

若 > 0 将信号发送给指定的进程;

= 0 将信号发送给当前的进程组;

= -1 将信号发送给每一个有权限接收这个信号的进程

<-1:这个pid=某个进程组的ID取反(-12345)

sig 需要发送的信号的编号或者是宏值,0表示不发送任何信号

raise

int raise(int sig); 给当前进程发送信号

sig 要发送的信号

返回值 成功:0 失败:非0

相当于 kill(getpid(), sig);

abort

void abort(void) ; 发送SIGABRT信号给当前的进程,杀死当前进程

相当于 kill(getpid(),SIGABRT);

alarm

unsigned int alarm(unsigned int seconds);

设置定时器(闹钟)。函数调用开始倒计时,当倒计时为0的时候,函数会给当前的进程发送一个信号 SIGALARM

seconds 倒计时的时长,单位:秒。如果参数为0,定时器无效(不进行倒计时,不发送信号)

取消一个定时器,通过alarm(0)

返回值 之前没有定时器返回0; 之前有定时器,则返回之前定时剩余的倒计时剩余的时间

#include <stdio.h>
#include <unistd.h>
int main(){
int seconds = alarm(5);
printf("seconds = %d\n",seconds);//返回0

sleep(2);

seconds = alarm(2);//不阻塞
printf("seconds = %d\n",seconds);//3

while(1){

}
return 0;
}
setitimer

int setitimer (int which, const struct itimerval *new_val, struct itimerval *old_value) ;

/*
功能:设置定时器(闹钟),可以替代alarm函数。精度us,可以实现周期性的定时
参数:
which:定时器以什么时间计时
ITIMER_REAL:真实时间,时间到达,发送SIGALRM 常用
ITIMER_VIRTUAL:用户时间,时间到达,发送SIGVTALRM
ITIMER_PROF:以该进程在用户态和内核态下所消耗的时间来计时,时间到达,发送SIGPROF

new_value:设置定时器的属性

struct itimerval {//定时器的结构体
struct timeval it_interval; //每个阶段的时间,间隔时间
struct timeval it_value; //延迟多长时间执行定时器
};

struct timeval {//时间的结构体
time_t tv_sec; //秒数
suseconds_t tv_usec; //微秒
};

过10s后每隔2s定时一次

old_value:记录上一次的定时的时间参数可以在这里获取到,用不到的话传递一个NULL

返回值:
成功 0
失败 -1 指定错误号
*/

#include <sys/time.h>
#include <stdio.h>
#include <stdlib.h>
//过3s后每隔2s定时一次
int main(){
struct itimerval new_value;
//设置值
new_value.it_interval.tv_sec=2;//设置间隔的时间
new_value.it_interval.tv_usec=0;

//设置延迟的时间,3s后开始第一次定时
new_value.it_value.tv_sec=3;//s
new_value.it_value.tv_usec=0;//us
int ret=setitimer(ITIMER_REAL,&new_value,NULL); //非阻塞的
printf("定时器开始了...\n"); // 这里过了3s接收到sigALARM信号直接就终止了,如果想不终止,需要捕捉到信号后自定义处理
if(ret==-1){
perror("setitimer");
exit(0);
}

getchar();
return 0;
}
信号捕捉
signal

sighandler_t signal (int signum, sighandler_t handler);

signum 要捕捉的信号,也可以是宏

handler 捕捉到信号要如何处理

  • SIG_IGN 忽略信号
  • SIG_DFL 使用信号默认的行为
  • 回调函数 自定义函数(返回值一定要是 void 参数一定要是 int类型(或者信号宏名))

返回值

  • 成功,返回上一次注册的信号处理函数的地址。第次调用返回NULL
  • 失败,返回SIG_ ERR, 设置错误号

SIGKILL SIGSTOP不能被捕捉或忽略

void signalhandler(int signo){    //signal函数会传递信号序号给这个函数
//信号处理函数
}
int main(){
...
signal(14, signalhandler); //收到14号信号「SIGALARM」时,就会执行对应的信号处理函数
...
return 0;
}
sigaction

int sigaction(int sig, const struct sigaction *restrict act, struct sigaction *restrict oact);

sig 指出要捕获的信号类型,act 指定新的信号处理方式,oact 输出先前信号的处理方式(如果不为NULL的话)。

#include <signal.h>
struct sigaction {
union __sigaction_u __sigaction_u; /* signal handler */
sigset_t sa_mask; /* signal mask to apply */
int sa_flags; /* see signal options below */
};

union __sigaction_u {
void (*__sa_handler)(int);
void (*__sa_sigaction)(int, siginfo_t *, void *);
};

#define sa_handler __sigaction_u.__sa_handler
#define sa_sigaction __sigaction_u.__sa_sigaction

sa_handler 此参数和signal()的参数handler相同,代表新的信号处理函数

sa_mask 用来设置在处理该信号时暂时将sa_mask 指定的信号集搁置

sa_flags 用来设置信号处理的其他相关操作,下列的数值可用

  • SA_RESETHAND:当调用信号处理函数时,将信号的处理函数重置为缺省值SIG_DFL

  • SA_RESTART:如果信号中断了进程的某个系统调用,则系统自动启动该系统调用

  • SA_NODEFER :一般情况下, 当信号处理函数运行时,内核将阻塞该信号。但是如果设置了 SA_NODEFER标记, 那么在该信号处理函数运行时,内核将不会阻塞该信号

  • 其他查阅 man sigaction

共享内存

共享内存允许两个或者多个进程共享物理内存的同一块区域。由于一个共享内存段会成为一个进程用户空间的一部分,因此这种IPC 机制无需内核介入。所有要做的就是让一个进程将数据复制进共享内存中,并且这部分数据会对其他所有共享同一个段的进程可用。

与管道等要求发送进程将数据从用户空间的缓冲区复制进内核内存和接收进程将数据从内核内存复制进用户空间的缓冲区的做法相比(管

道是一种存在于内核的文件),这种IPC技术的速度更快。

共享内存使用步骤
  1. 调用shmget()创建一个新共享内存段或取一个既有共享内存段的标识符。这个调用将返回后续调用中需要用到的共享内存标识符

  2. 使用shmat()和当前进程进行关联, 返回已开辟的内存的首地址

    此刻在程序中可以像对待其他可用内存那样对待这个共享内存段。为引用这块共享内存,程序需要使用由shmat() 返回的addr 值,它是一个指向进程的虛拟地址空间中该共享内存段的起点的指针。

  3. 调用shmdt() 来分离共享内存段。在这个调用之后,进程就无法再引用这块共享内存了。这一步是可选的,并且在进程终止时会自动完成这一步

  4. 调用shmctl()来删除共享内存段。只有当当前所有附加内存段的进程都与之分离之后内存段才会销毁。只有一个进程需要执行这一步

共享内存使用函数
shmget

int shmget(key_t key, size_t size, int shmflg);

成功完成后,将共享内存段标识符为返回。否则,返回-1并设置全局变量errno表示错误

key
0(IPC_PRIVATE):会建立新共享内存对象
大于032位整数:视参数shmflg来确定操作。通常要求此值来源于ftok返回的IPC键值

size
大于0的整数:新建的共享内存大小,以字节为单位
0:只获取共享内存时指定为0

shmflg
0:取共享内存标识符,若不存在则函数会报错

IPC_CREAT:当shmflg&IPC_CREAT为真时,如果内核中不存在键值与key相等的共享内存,则新建一个共享内存;如果存在这样的共享内存,返回此共享内存的标识符

IPC_CREAT|IPC_EXCL:如果内核中不存在键值与key相等的共享内存,则新建一个消息队列;如果存在这样的共享内存则报错
shmat

void* shmat(int shmid, const void * shmaddr, int shmflg);

成功:附加好的共享内存地址 出错返回-1,错误原因存于error中

shmid 共享内存标识符

shmaddr 指定共享内存出现在进程内存地址的什么位置,直接指定为NULL让内核自己决定一个合适的地址位置

shmflg SHM_RDONLY:为只读模式, 0:为读写模式

shmdt

int shmdt (const void *shmaddr) ;

成功返回0 出错返回-1,错误原因存于error中

shmaddr 连接的共享内存的起始地址

shmctl

int shmctl (int shmid, int cmd, struct shmid_ds *buf) ;

成功:0 出错:-1,错误原因存于error中

shmid:共享内存标识符

cmd

IPC_STAT:得到共享内存的状态,把共享内存的shmid_ds结构复制到buf中

IPC_SET:改变共享内存的状态,把buf所指的shmid_ds结构中的uid、gid、mode复制到共享内存的shmid_ds结构内

IPC_RMID:删除这片共享内存

buf 需要设置或者获取的共享内存的属性信息, 一般写NULL

IPC_ STAT buf存储数据

IPC_ SET buf中需要初始化数据,设置到内核中

IPC_ RMID 没有用,NULL

// /home/kjg/Linux/sharememory/write_shm.c
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <string.h>

int main(){
// 1.创建一块共享内存
int shmid = shmget(100, 4096, IPC_CREAT | 0664);
printf("shmid: %d", shmid);

// 2.和当前进程进行关联, 返回已开辟的内存的首地址
void* addr = shmat(shmid, NULL, 0);

// 3.写数据
char * str = "helloworld";
memcpy(addr, str, strlen(str) + 1);

// 按任意键继续
printf("按任意键继续\n");
getchar();

// 4.解除关联
shmdt(addr);

// 5.释放共享内存
shmctl(shmid, IPC_RMID, NULL);

return 0;
}
ftok

key_t ftok(const char *pathname, int proj_id);

image-20221105134241461
操作系统如何知道一块共享内存被多少个进程关联
  • 共享内存维护了一个结构体struct shmid_ds 这个结构体中有一个成员shm_nattach 记录了关联的进程个数
  • ipcs用法

ipcs -a //打印当前系统中所有的进程间通信方式的信息

ipcs -m //打印出使用共享内存进行进程间通信的信息

ipcs -q //打印出使用消息队列进行进程间通信的信息

ipcs -s //打印出使用信号进行进程间通信的信息

  • ipcrm用法

ipcrm -M shmkey //移除用shmkey创建的共享内存段

ipcrm -m shmid // 移除用 shmid标识的共享内存段

ipcrm -Q msgkey //移除用msqkey创建的消息队列

ipcrm -q msqid //移除用msqid标识的消息队列

ipcrm -S semkey //移除用semkey创建的信号

ipcrm -s semid // 移除用semid标识的信号

终端、进程组、会话

终端
  • 查看当前终端的pid echo $$

  • 在UNIX系统中,用户通过终端登录系统后得到一个shell 进程,这个终端成为shell 进程的控制终端(Controlling Terminal) ,进程中,控制终端是保存在PCB 中的信息,而fork() 会复制PCB中的信息,因此由shell 进程启动的其它进程的控制终端也是这个终端。

  • 默认情况下(没有重定向),每个进程的标准输入、标准输出和标准错误输出都指向控制终端,进程从标准输入读也就是读用户的键盘输入,进程往标准输出或标准错误输出写也就是输出到显示器上。

  • 在控制终端输入一些特殊的控制键可以给前台进程发信号,例如Ctrl + C会产生SIGINT 信号,Ctrl + \会产生SIGQUIT 信号。

进程组
  • 进程组和会话在进程之间形成了一种两级层次关系:进程组是一组相关进程的集合,会话是一组相关进程组的集合。进程组和会话是为支持shell 作业控制而定义的抽象概念,用户通过shell 能够交互式地在前台或后台运行命令。

  • 进程组由一个或多个共享同一进程组标识符(PGID) 的进程组成。一个进程组拥有一个进程组首进程,该进程是创建该组的进程,其进程ID为该进程组的ID, 新进程会继承其父进程所属的进程组ID

  • 进程组拥有一个生命周期,其开始时间为首进程创建组的时刻,结束时间为最后一个成员进程退出组的时刻。一个进程可能会因为终止而退出进程组,也可能会因为加入了另外一个进程组而退出进程组。进程组首进程无需是最后一个离开进程组的成员

会话
  • 会话是一组进程组的集合。会话首进程是创建该新会话的进程,其进程ID会成为会话ID。新进程会继承其父进程的会话ID。

  • 一个会话中的所有进程共享单个控制终端。控制终端会在会话首进程首次打开一个终端设备时被建立。一个终端最多可能会成为一个会话的控制终端

  • 在任一时刻,会话中的其中一个进程组会成为终端的前台进程组,其他进程组会成为后台进程组。只有前台进程组中的进程才能从控制终端中读取输入。当用户在控制终端中输入命令后,该信号会被发送到前台进程组中的所有成员。

  • 当控制终端的连接建立起来之后,会话首进程会成为该终端的控制进程

image-20221105165609594
相关函数

pid_t getpgrp(void); 用来取得进程所属的组识别码。此函数相当于调用 getpgid(0);

pid_t getpgid(pid_t pid);

int setpgid(pid_t pid, pid_t pgid);

pid_t getsid(pid_t pid);

pid_t setsid(void);

守护进程

  • 守护进程 (Daemon Process) ,也就是通常说的精灵进程,是Linux中的后台服务进程。它是一个生存期较长的进程,通常独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。一般采用以d结尾的名字。
  • 守护进程具备下列特诊:
    • 生命周期很长,守护进程会在系统启动的时候被创建并一直运行直至系统被关闭。
    • 它在后台运行并且不拥有控制终端。没有控制终端确保了内核永远不会为守护进程自动生成任何控制信号以及终端相关的信号(如SIGINT、 SIGQUIT)。
  • Linux 的大多数服务器就是用守护进程实现的。比如,Internet 服务器inetd, web服务器httpd 等。
守护进程的创建步骤

image-20221105181155408

/*实现一个守护进程的实例(每隔 10s 在/tmp/dameon.log 写入一句话)*/

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<fcntl.h>
#include<unistd.h>
#include<sys/wait.h>
#include<sys/types.h>
#include<sys/stat.h>
#define MAXFILE 65535

int main(){
pid_t pc;
int i,fd,len;
char *buf = "this is a Dameon\n";
len = strlen(buf);

pc = fork(); /*第一步: 创建子进程,父进程退出*/
if(pc<0){
printf("error fork\n");
exit(1);
}
else if(pc>0){ //子进程号=0,父进程号大于0
exit(0);//父进程退出,子进程成为孤儿进程
}

setsid(); /*第二步:setsid() 函数用于创建一个新的会话,并担任该会话组的组长
调用setid作用:1、让进程摆脱原会话控制; 2、让进程摆脱原进程组的控制; 3、让进程摆脱原控制终端的控制*/

chdir("/"); /*第三步:改变当前目录为根目录*/

umask(0); /*第四步:重设文件权限掩码*/

for(i=0;i<MAXFILE;i++){
close(i);
} /*第五步:关闭文件描述符*/

while(1){
if((fd=open("/tmp/dameon.log",O_CREAT|O_WRONLY|O_APPEND,0600))<0){
perror("open");
exit(1);
}
write(fd,buf,len+1);
close(fd);
sleep(10);
}
return 0;
}

Linux多线程开发

线程概述

  • 与进程(process) 类似,线程(thread)是允许应用程序并发执行多个任务的一种机制。一个进程可以包含多个线程。同一个程序中的所有线程均会独立执行相同程序,且共享同一份全局内存区域,其中包括初始化数据段、未初始化数据段,以及堆内存段。(传统意义上的UNIX进程只是多线程程序的一个特例,该进程只包含一个线程)
  • 进程是CPU分配资源的最小单位,线程是操作系统调度执行的最小单位
  • 线程是轻量级的进程 (LWP: Light Weight Process) ,在Linux环境下线程的本质仍是进程
  • 查看指定进程的 LWP 号: ps -Lf pid
线程和进程的区别
  • 进程间的信息难以共享。 由于除去只读代码段外,父子进程并未共享内存,因此必须采一些进程间通信方式,在进程间进行信息交换

  • 调用fork()来创建进程的代价相对较高,即便利用写时复制技术,仍热需要复制诸如内存页表和文件描述符表之类的多种进程属性,这意味着fork() 调用很慢

  • 线程之间能够方便、快速地共享信息。只需将数据复制到共享(全局或堆)变量中即可

  • 创建线程比创建进程通常要快10倍甚至更多。线程间是共享虚拟地址空间的,无需采用写时复制来复制内存,也无需复制页表

线程和进程虚拟地址空间

子进程复制父进程的虚拟地址空间,而线程间共享进程的虚拟地址空间,只不过各线程在 栈空间 .text段占据了各一部分

image-20221106215842522
线程之间共享和非共享资源
image-20221106220232940

线程相关函数

pthread_t pthread_self(void);

int pthread_equal(pthread_t t1, pthread_t t2);

int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void* (*start routine) (void *), void *arg);

void pthread_exit(void *retval);

int pthread_join(pthread_t thread, void **retval);

int pthread_detach(pthread_t thread);

int pthread_cancel(pthread_t thread);

pthread_create

int pthread_create(pthread_t* thread, const pthread_attr_t* attr, void* (*start routine)(void *), void* arg);

创建一个子线程

thread 传出参数,线程创建成功后,子线程的线程ID被写到该变量

attr 设置线程的属性,一般使用默认值,NULL

start_ routine 函数指针,这个函数是子线程需要处理的逻辑代码

arg 给第三个参数使用,传参

返回值: 成功: 0 失败:返回错误号。这个错误号和之前errno不太一样。

获取错误号的信息: char * strerror(int errnum);

pthread_self

pthread_t pthread_self(void);

获取当前线程id

pthread_equal

int pthread_equal(pthread_t t1, pthread_t t2);

判断两个线程号是否相等

pthread_exit

void pthread_exit(void *retval);

主线程退出时,不影响其他线程的运行

子线程中 return NULL 相当于 pthread_exit(NULL)

pthread_ join

int pthread_join(pthread_t thread, void **retval);

pthread_t thread 被连接线程的线程号

void **retval 指向 一个指向被连接线程的返回码的指针 的指针

返回值 线程连接的状态,0是成功,非0是失败

在很多情况下,主线程生成并起动了子线程,如果子线程里要进行大量的耗时的运算,主线程往往将于子线程之前结束,但是如果主线程处理完其他的事务后,需要用到子线程的处理结果。也就是主线程需要等待子线程执行完成之后再结束,就要用pthread_join()

#include "stdafx.h" 
#include <pthread.h>
#include <stdio.h>
#include <Windows.h>
#pragma comment(lib, "pthreadVC2.lib")

static int count = 0;
void* thread_run(void* parm) {
for(int i=0; i<5; i++) {
count ++;
printf("The thread_run method count is = %d\n",count);
Sleep(1000);
}
return NULL;
}
int main(){
pthread_t tid;
pthread_create(&tid, NULL, thread_run, NULL);

// 加入pthread_join后,主线程"main"会一直等待直到tid这个线程执行完毕自己才结束
// 一般项目中需要子线程计算后的值就需要加join方法
pthread_join(tid, NULL);

// 如果没有join方法可以看看打印的顺序
printf("The count is = %d\n",count);

getchar();
return 0;
}

当A线程调用线程B并 pthread_join() 时,A线程会处于阻塞状态,直到B线程结束后,A线程才会继续执行下去

当 pthread_join() 函数返回后,被调用线程才算真正意义上的结束,它的内存空间也会被释放(如果被调用线程是非分离的)

  1. 被释放的内存空间仅仅是系统空间,你必须手动清除程序分配的空间,比如 malloc() 分配的空间。
  2. 一个线程只能被一个进程所连接。
  3. 被连接的线程必须是非分离的,否则连接会出错。

所以可以看出pthread_join()有两种作用:

(1) 用于等待其他线程结束:调用pthread_join() 后, 当前线程会处于阻塞状态, 直到被调用的线程结束后当前线程才会重新开始执行

(2) 对线程的资源进行回收:如果一个线程是非分离的(默认情况下创建的线程都是非分离)并且没有对该线程使用 pthread_join() 的话,该线程结束后并不会释放其内存空间,这会导致该线程变成了“僵尸线程”

pthread_detach

int pthread_detach (pthread_t thread);

在任何一个时间点上,线程是可结合的,或者是分离的。一个可结合的线程能够被其他线程收回其资源和杀死;在被其他线程回收之前,它的存储器资源(如栈)是不释放的

一个分离的线程是不能被其他线程回收或杀死的,它的存储器资源在它终止时由系统自动释放

>pthread_t tid;
int status = pthread_create(&tid, NULL, ThreadFunc, NULL);
if(status != 0){
perror("pthread_create error");
}
pthread_detach(tid);
pthread_cancel

int pthread_cancel (pthread_t thread);

功能:取消线程(终止) 如果成功,返回0;如果发生错误,则返回非零值错误的数字

pthread_cancel并不立刻让线程终止,它只提出请求。线程在取消请求(pthread_cancel)发出后会继续运行,直到到达某个取消点(CancellationPoint)。取消点是线程检查是否被取消并按照请求进行动作的一个位置。

pthread标准指定了几个取消点,其中包括:

  1. 通过pthread_testcancel调用以编程方式建立线程取消点。
  2. 线程等待pthread_cond_wait或pthread_cond_timewait()中的特定条件。
  3. 被sigwait(2)阻塞的函数
  4. 一些标准的库调用。通常,这些调用包括线程可基于阻塞的函数。

设置线程属性

int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void * (*start routine) (void *) , void *arg);

中第二个参数就是线程属性

相关函数

int pthread_attr_init(pthread_attr_t *attr);

int pthread_attr_destroy(pthread_attr_t *attr);

int pthread_attr_getdetachstate(const pthread_attr_t *attr, int detachstate);

int pthread_attr_setdetachstate(pthread_attr_t *attr, int detachstate);

// 设置线程分离状态属性
pthread_t tid;
pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
pthread_create(&tid, &attr, THREAD_FUNCTION, arg);

线程同步

  • 线程的主要优势在于,能够通过全局变量来共享信息。不过,这种便捷的共享是有代价的:必须确保多个线程不会同时修改同一变量,或者某一线程不会读取正在由其他线程修改的变量。
  • 临界区是指访问某一共享资源的代码片段,并且这段代码的执行应为原子操作,也就是同时访问同一共享资源的其他线程不应终端该片段的执行。
  • 线程同步:即当有一个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作,直到该线程完成操作,其他线程才能对该内存地址进行操作,而其他线程则处于等待状态。
//目标,结果: 三个窗口,每个窗口都卖一百张票
#include<stdio.h>
#include<pthread.h>

void* sellticket(void * arg){
int tickets = 100;
while(tickets > 0){
printf("%ld正在卖第%d 张票\n", pthread_self() ,tickets);
tickets --;
}
return NULL;
}

int main(){

// 创建3个子线程
pthread_t tid1,tid2,tid3;
pthread_create(&tid1, NULL, sellticket, NULL);
pthread_create(&tid2, NULL, sellticket, NULL);
pthread_create(&tid3, NULL, sellticket, NULL);

//回收资源
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
pthread_join(tid3, NULL);

//退出主线程
pthread_exit(NULL);

//退出进程
return 0;
}
//目标: 三个窗口,共卖一百张票, 但结果通常办不到
#include<stdio.h>
#include<pthread.h>

int tickets = 100;
void* sellticket(void * arg){
while(tickets > 0){
usleep(10000); // tid1 进入循环休眠时, tid2,tid3都可能进入循环并执行过程
printf("%ld正在卖第%d 张票\n", pthread_self() ,tickets);
tickets --;
}
return NULL;
}

int main(){

// 创建3个子线程
pthread_t tid1,tid2,tid3;
pthread_create(&tid1, NULL, sellticket, NULL);
pthread_create(&tid2, NULL, sellticket, NULL);
pthread_create(&tid3, NULL, sellticket, NULL);

//回收资源
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
pthread_join(tid3, NULL);

//退出主线程
pthread_exit(NULL);

//退出进程
return 0;
}

// 结果:
// 548136161792正在卖第87 张票
// 548119376384正在卖第87 张票

// 547602481664正在卖第1 张票
// 547594088960正在卖第0 张票
// 547585696256正在卖第-1 张票
互斥锁
  • 为避免线程更新共享变量时出现问题,可以使用互斥量(mutex )来确保同时仅有一个线程可以访问某项共享资源。可以使用互斥量来保证对任意共享资源的原子访问
  • 一旦线程锁定互斥量,随即成为该互斥量的所有者,只有所有者才能给互斥量解锁。一般情况下,对每一共享资源(可能由多个相关变量组成)会使用不同的互斥量,每一线程在访问同一资源时将采用如下协议:
    • 针对共享资源锁定互斥量
    • 访问共享资源
    • 对互斥量解锁
相关函数

互斥量的类型 pthread_mutex_t

int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);

mutex 需要初始化的互斥量变量

attr 互斥量相关的属性,NULL

restrict C语言的修饰符,被修饰的指针的内容不能由另外的一个指针进行操作

pthread_mutex_t *restrict mutex = XXX;
pthread_mutex_t * mutex1 = mutex;
* mutex1 = ZZZ; // 错误,因为 restrict mutex

int pthread_mutex_destroy(pthread_mutex_t *mutex);

释放互斥量的资源

int pthread_mutex_lock(pthread_mutex_t *mutex);

上锁,阻塞

int pthread_mutex_trylock(pthread_mutex_t *mutex) ;

上锁,非阻塞,如果加锁失败,直接返回

int pthread_mutex_unlock(pthread_mutex_t *mutex) ;

解锁

lock_guard(mutex& m);

static int g_count = 100;
void fun_count1(std::mutex* mutex) {
while(g_count > 0){
//加锁
mutex->lock();
if(g_count > 0){
cout <<"fun_count1:"<<--g_count<<endl;
}
//解锁
mutex->unlock();
usleep(1000*500);
}
}

void fun_count2(std::mutex* mutex) {
auto fun = [&]{
//构造时自动加锁
std::lock_guard<std::mutex> lock(*mutex);
if(g_count > 0){
cout <<"fun_count2:"<<--g_count<<endl;
}
//析构自动解锁
};
while(g_count > 0){
fun();
usleep(1000*500);
}
}
案例改进版
// 三个窗口,卖一百张票
#include<stdio.h>
#include<pthread.h>

int tickets = 100;

//创建一个互斥量
pthread_mutex_t mutex;

void* sellticket(void * arg){

while(1){
pthread_mutex_lock(&mutex);

if (tickets > 0){
usleep(10000);
printf("%ld正在卖第%d 张票\n", pthread_self() ,tickets);
tickets --;
}else{
pthread_mutex_unlock(&mutex);
break;
}
}
pthread_mutex_unlock(&mutex);

return NULL;
}

int main(){
//初始化互斥量
pthread_mutex_init(&mutex, NULL);

// 创建3个子线程
pthread_t tid1,tid2,tid3;
pthread_create(&tid1, NULL, sellticket, NULL);
pthread_create(&tid2, NULL, sellticket, NULL);
pthread_create(&tid3, NULL, sellticket, NULL);

//回收资源
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
pthread_join(tid3, NULL);

//退出主线程
pthread_exit(NULL);

//释放互斥量
pthread_mutex_destroy(&mutex);

//退出进程
return 0;
}
死锁

image-20221108162830690

读写锁
  • 当有一个线程已经持有互斥锁时,互斥销将所有试图进入临界区的线程都阻塞住。但是考虑一种情形,当前持有互斥锁的线程只是要读访问共享资源,而同时有其它几个线程也想读取这个共享资源,但是由于互斥锁的排它性,所有其它线程都无法获取锁,也就无法读访问共享资源了,但是实际上多个线程同时读访问共享资源并不会导致问题
  • 在对数据的读写操作中, 更多的是读操作,写操作较少,例如对数据库数据的读写应用。为了满足当前能够允许多个读出,但只允许一个写入的需求,线程提供了读写锁来实现。
  • 读写锁的特点:
    • 如果有其它线程读数据,则允许其它线程执行读操作,但不允许写操作
    • 如果有其它线程写数据,则其它线程都不允许读、写。 写的优先级高
相关函数

读写锁的类型 pthread_rwlock_t

int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);

int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);

int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);

int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);

int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);

int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);

int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

案例
#include <stdio.h> 
#include <pthread.h>
#include <unistd.h>

int num = 1;
pthread_rwlock_t rwlock; // 声明失败是因为与C99冲突

void* writeNum(void * arg){
while (1){
pthread_rwlock_wrlock(&rwlock);
num ++;
printf("write===pid : %ld, num : %d\n", pthread_self(), num);
pthread_rwlock_unlock(&rwlock);
usleep(100);
}
return NULL;
}

void* readNum(void * arg){
while (1){
pthread_rwlock_rdlock(&rwlock);
printf("read===pid : %ld, num : %d\n", pthread_self(), num);
pthread_rwlock_unlock(&rwlock);
usleep(100);
}
return NULL;
}

int main(){

pthread_rwlock_init(&rwlock, NULL);

//创建三个写进程,五个读进程
pthread_t wtids[3], rtids[5];
int i;
for(i = 0; i < 3; i++){
pthread_create(&wtids[i], NULL, writeNum, NULL);
pthread_detach(wtids[i]); // 设置线程分离, 线程结束后由系统释放
}
for(i = 0; i < 5; i++){
pthread_create(&rtids[i], NULL, readNum, NULL);\
pthread_detach(rtids[i]); // 设置线程分离, 线程结束后由系统释放
}

pthread_exit(0); // 不写这段话,直接进程退出,所有线程都没了.
//若写了则主线程退出,子线程都设置了分离,运行完系统回收

pthread_rwlock_destroy(&rwlock);

return 0;
}
条件变量(不是锁,不好用)

条件变量的类型 pthread_cond_t

int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);

int pthread_cond_destroy(pthread_cond_t *cond);

int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t * restrict mutex);

阻塞函数,调用了该函数,线程会阻塞等待

int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const struct timespec *restrict abstime);

阻塞函数,调用了该函数,线程会阻塞等待,直到指定的时间结束

int pthread_cond_signal(pthread_cond_t *cond);

唤醒一个或者多个

int pthread_cond_broadcast(pthread_cond_t *cond);

唤醒所有

信号量

信号量的类型 sem_t

int sem_init(sem_t *sem, int pshared, unsigned int value) ;

sem 信号量变量的地址

pshared 0用在线程间,非0用在进程间

value 信号量中的值

int sem_destroy(sem_t *sem);

int sem_wait(sem_t *sem); P操作

①S减1

②若S减1后仍大于或等于0,则进程继续执行

③若S减1后小于0,则该进程被阻塞后放入等待该信号量的等待队列中,然后转进程调度

int sem_trywait(sem_t *sem);

int sem_timedwait(sem_t * sem, const struct timespec *abs_timeout);

int sem_post(sem_t *sem); V操作

①S加1

②若相加后结果大于0,则进程继续执行

③若相加后结果小于或等于0,则从该信号的等待队列中释放一个等待进程,然后再返回原进程继续执行或转进程调度

int sem_getvalue(sem_t *sem, int *sval);

要配合mutex一起用

image-20221109231419437

Linux网络编程

BS和CS架构

C/S

C/S架构是第一种比较早的软件架构,主要用于局域网内。也叫客户机/服务器模式

它可以分为客户机和服务器两层:

  • 第一层: 在客户机系统上结合了界面显示与业务逻辑
  • 第二层: 通过网络结合了数据库服务器

客户端不仅仅是一些简单的操作,它也是会处理一些运算,业务逻辑的处理等。也就是说,客户端也做着一些本该由服务器来做的一些事情,如图所示:

img

C/S架构软件有一个特点,就是如果用户要使用的话,需要下载一个客户端,安装后就可以使用。比如QQ,OFFICE软件等

C/S架构的优点:

  1. C/S架构的界面和操作可以很丰富。(客户端操作界面可以随意排列,满足客户的需要)
  2. 安全性能可以很容易保证。(因为只有两层的传输,而不是中间有很多层)
  3. 由于只有一层交互,因此响应速度较快。(直接相连,中间没有什么阻隔或岔路,比如QQ,每天那么多人在线,也不觉得慢)

C/S架构的缺点:

  1. 适用面窄,通常用于局域网中
  2. 用户群固定。由于程序需要安装才可使用,因此不适合面向一些不可知的用户
  3. 维护成本高,发生一次升级,则所有客户端的程序都需要改变
B/S

B/S架构的全称为Browser/Server,即浏览器/服务器结构。

Browser指的是Web浏览器,极少数事务逻辑在前端实现,但主要事务逻辑在服务器端实现

其实就是我们前端现在做的一些事情,大部分的逻辑交给后台来实现,我们前端大部分是做一些数据渲染,请求等比较少的逻辑。

B/S架构的优点:

  1. 成本低,方便维护,分布性强,开发简单
  2. BS架构无需升级多个客户端,升级服务器即可。可以随时更新版本,而无需用户重新下载啊什么的。

B/S架构的缺点:

  1. 在跨浏览器上,BS架构不尽如人意。
  2. 协议一般是固定的 http/https ,所以无法操作大数据量的文件
  3. 无法实现个性化
  4. 在速度和安全性上无法保证

MAC地址,IP地址和端口

MAC地址

又称以太网地址,物理地址

网卡是一块被设计用来允许计算机在计算机网络上进行通讯的计算机硬件,又称为网络适配器或网络接口卡NIC。其拥有MAC地址,属于OSI模型的第2层,它使得用户可以通过电缆或无线相互连接。每一个网卡都有一个被称为MAC地址的独一无二的48位串行号

网卡的主要功能: 1 .数据的封装与解封装 2.链路管理 3.数据编码与译码 一台设备可以有多个网卡

image-20221110150321181
IP地址

IP协议是为计算机网络相互连接进行通信而设计的协议。在因特网中,它是能使连接到网上的所有计算机网络实现相互通信的一套规则,规定了计算机在因特网.上进行通信时应当遵守的规则。

任何厂家生产的计算机系统,只I守IP协议就可以与因特网互连互通。各个厂家生产的网络系统和设备,如以太网、分组交换网等,它们相互之间不能互通,不能互通的主要原因是因为它们所传送数据的基本单元(技术上称之为“帧”)的格式不同。

IP 协议实际上是一套由软件程序组成的协议软件,它把各种不同”帧”统-转换成”IP 数据报”格式,这种转换是因特网的一个最重要的特点,使所有各种计算机都能在因特网上实现互通,即具有”开放性”的特点。正是因为有了IP协议,因特网才得以迅速发展成为世界上最大的、开放的计算机通信网络。因此,IP 协议也可以叫做”因特网协议

IP地址(Internet Protocol Address)是指互联网协议地址,又译为网际协议地址。IP 地址是IP协议提供的一种统一的地址格式,它为互联网上的每一个网络和每一 台主机分配一个逻辑地址, 以此来屏蔽物理地址的差异。

IP地址是一个32位的二进制数,通常被分割为4个“8位二进制数”(也就是4个字节)。IP 地址通常用”点分十进制”表示成(a.b.c.d) 的形式,其中,a,b,c,d都是 0~255之间的十进制整数

IP分类:

img

A类地址
为大型网络而设计的,网络地址的最高位必须是“0”, 地址范围从1.0.0.0 到127.0.0.0)。可用的A类网络有127个,每个网络能容纳16777214个主机。其中127.0.0.1是一个特殊的IP地址,表示主机本身,用于本地机器的测试

注:A: 0-127,其中0代表本网络的主机127为回环测试地址,因此,A类ip地址的实际范围是1-126. 默认子网掩码为255.0.0.0

B类地址
一个B类IP地址由2个字节的网络地址和2个字节的主机地址组成,网络地址的最高位必须是“10”,地址范围从128.0.0.0到191.255.255.255。可用的B类网络有16382个,每个网络能容纳6万多个主机 。

注: B:128-191,其中128.0.0.0和191.255.0.0为保留ip,实际范围是128.1.0.0–191.254.0.0

C类地址
一个C类IP地址由3字节的网络地址和1字节的主机地址组成,网络地址的最高位必须是“110”。范围从192.0.0.0到223.255.255.255。C类网络可达209万余个,每个网络能容纳254个主机。

注:C:192-223,其中192.0.0.0和223.255.255.0为保留ip,实际范围是192.0.1.0–223.255.254.0

D类地址
用于多点广播(Multicast)。 D类IP地址第一个字节以“1110”开始,它是一个专门保留的地址。它并不指向特定的网络,目前这一类地址被用在多点广播(Multicast)中。多点广播地址用来一次寻址一组计算机,它标识共享同一协议的一组计算机。224.0.0.0到239.255.255.255用于多点广播

E类IP地址 以“1111”开始,为将来使用保留。240.0.0.0到255.255.255.254,255.255.255.255用于广播地址

端口

用来找到某个网络中的特定应用,一个应用可以同时有多个端口

“端口”是英文port的意译,可以认为是设备与外界通讯交流的出口。端口可分为虚拟端口和物理端口,其中虚拟端口指计算机内部或交换机路由器内的端口,不可见,是特指TCP/IP协议中的端口,是逻辑意义上的端口。例如计算机中的80端口、21 端口、23端口等

物理端口又称为接口,是可见端口,计算机背板的RJ45网口,交换机路由器集线器等R]45端口。电话使用R]11插口也属于物理端口的范畴

如果把IP地址比作一间房子,端口就是出入这间房子的门。真正的房子只有几个门,但是一个IP地址的端口可以有65536 (即: 2^16) 个之多!端口是通过端口号来标记的,端口号只有整数,范围是从0到65535 (2^16-1)

端口分类

1.周知端口
周知端口是众所周知的端口号,也叫知名端口、公认端口或者官用端口,范围从0到1023,它们紧密绑定于一些特定的服务。例如80端口分配给WWW服务, 21端口分配给FTP服务, 23端口分配给Telnet服务等等。我们在IE的地址栏里输入一个网址的时候是不必指定端口号的,因为在默认情况下WWW服务的端口是“80”。网络服务是可以使用其他端口号的,如果不是默认的端口号则应该在地址栏上指定端口号,方法是在地址后面加上冒号”:” (半角),再加上端口号。比如使用“8080”作为WWW服务的端口,则需要在地址栏里输入“网址:8080”。但是有些系统协议使用固定的端口号,它是不能被改变的,比如139端口专门用于NetBIOS与TCP/IP之间的通信,不能手动改变。

2.注册端口
端口号从1024到49151,它们松散的绑定于一些服务,也就是说有许多服务绑定于这些端口,这些端口同样用于其他许多目的,如:许多系统处理端口从1024开始

3.动态端口/私有端口
动态端口的范围是从49152到65535。之所以称为动态端口,是因为它一般不固定分配某种服务,而是动态分配

网络模型

OSI七层模型

image-20221110155956089

三种模型对比

image-20221110160740252

封装(TCP/IP结构)

image-20221111140220057

分用

image-20221111140701837

image-20221111140730605

协议

应用层常见的协议有: FTP协议(File Transfer Protocol文件传输协议)、HTTP协议 (Hyper Text Transfer Protocol超文本传输协议)、NFS (Network File System网络文件系统)。

传输层常见协议有: TCP协议(Transmission Control Protocol传输控制协议)、UDP协议(User Datagram Protocol用户数据报协议)。

网络层常见协议有: IP协议(Internet Protocol因特网互联协议)、ICMP协议(Internet Control Message Protocol因特网控制报文协议)、IGMP协议(Internet Group Management Protocol因特网组管理协议)。

网络接口层常见协议有: ARP协议(Address Resolution Protocol地址解析协议)、RARP协议 (Reverse Address Resolution Protocol反向地址解析协议)。

UDP头部格式

image-20221110162651180

TCP头部格式

image-20221110162731372

image-20221110162838743

image-20221110162859467

IPv4头部结构

image-20221110162942609

image-20221110163111692

以太网帧格式

image-20221110163326444

ARP报文格式

image-20221110163211127

socket

简介

socket是一个接口,在用户进程与TCP/IP协议之间充当中间人,完成TCP/IP协议的书写,用户只需理解接口即可

img

Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口。在设计模式中,Socket其实就是一个门面模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议

socket本身有”插座”的意思,在Linux环境下,用于表示进程间网络通信的特殊文件类型。本质为内核借助缓冲区形成的伪文件。既然是文件,那么理所当然的,我们可以使用文件描述符引用套接字。与管道类似的,Linux 系统将T封装成文件的目的是为了统-接口,使得读写套接字和读写文件的操作一致。区别是管道主要应用于本地进程间通信,而套接字多应用于网络进程间数据的传递。

字节序

字节序分为大端字节序(Big-Endian) 和小端字节序(ittle-Endian)

大端字节序是指一个整数的最高位字节(2331 It)存储在内存的低地址处,低位字节(0 7 bit)存储在内存的高地址处;

小端字节序则是指整数的高位字节存储在内存的高地址处,而低位字节则存储在内存的低地址处

字节序转换函数

当格式化的数据在两台使用不同字节序的主机之间直接传递时,接收端必然错误的解释之。解决问题的方法是:发送端总是把要发送的数据转换成大端字节序数据后再发送,而接收端知道对方传送过来的数据总是采用大端字节序,所以接收端可以根据自身采用的字节序决定是否对接收到的数据进行转换(小端机转换,大端机不转换)。网络字节顺序是TCP/IP 中规定好的一种数据表示格式,它与具体的CPU类型、操作系统等无关,从而可以保证数据在不同主机之间传输时能够被正确解释,网络字节顺序采用大端排序方式

BSD Socket提供了封装好的转换接口,方更程序员使用。包括

从主机字节序到网络字节序的转换函数: htons、htonl;

从网络字节序到主机字节序的转换函数: ntohs、 ntohl。

h- host主机,主机字节序
to- 转换成什么
n- network 网络字节序
s- short
#include <arpa/inet.h>
// 转换端口的 端口16位
uint16_t htons(uint16_t hostshort); //将一个无符号短整型数值转换为网络字节序,即大端模式(big-endian)

uint16_t ntohs (uint16_t netshort);

// 转换IP的,IP地址32位
uint32_t hton1(uint32_t hostlong);

uint32_t ntoh1(uint32_t net1ong);

// 例子
#include <stdio.h>
#include <arpa/inet.h>
int main() {
// htons 主机端口 -> 网络端口
unsigned short a = 0x0102;
printf("a : %x\n", a);
unsigned short b = htons(a);
printf("b : %x\n", b);

// htonl 主机IP -> 网络IP
char buf[4] = {192, 168, 1, 100};
int num = *(int *)buf;
int sum = htonl(num);
unsigned char *p = (unsigned char *)&sum;
printf("%d %d %d %d\n", *p, *(p+1), *(p+2), *(p+3));
return 0;
}
IP转换函数(有字节序转换的功能)

通常,人们习惯用可读性好的字符串来表示IP地址,比如用点分十进制字符串表示IPv4地址,以及用十六进制字符串表示IPv6地址。

但编程中我们需要先把它们转化为整数(二进制数)方能使用。

而记录日志时则相反,我们要把整数表示的IP地址转化为可读的字符串。

下面 3个函数可用于用点分十进制字符串表示的IPv4地址和用网络字节序整数表示的IPv4地址之间的转换:

/*
#include <arpa/inet.h>
in_addr_t inet_addr (const char *cp);
int inet_aton(const char *cp, struct in_addr *inp);
char *inet_ntoa(struct in_addr in);
*/

下面这对更新的函数也能完成前面3个函数同样的功能,并且它们同时适用IPv4地址和IPv6地址:

inet_pton

int inet_pton(int af, const char *src, void *dst); 将点分十进制的ip地址转化为用于网络传输的数值格式

af 选择ipv4还是ipv6 AF_INET 或者 AF_INET6

src 需要转换的点分十进制IP字符串

dst 传出参数,数据转换后保存在dst中

返回值:若成功则为1,若输入不是有效的表达式则为0,若出错则为-1

inet_ntop

const char *inet_ntop(int af, const void *src, char *dst, socklen_t size); 将数值格式转化为点分十进制的ip地址格式

af 选择ipv4还是ipv6 AF_INET 或者 AF_INET6

src 需要转换的整数的地址

dst 传出参数,数据转换后保存在dst中(IP字符串)

size 指定dst的大小(数组的容量)

返回值:若成功则为转换后的字符串的指针(与dst为同一个值),若出错则为NULL

#include<stdio.h>
#include<arpa/inet.h>
#include<iostream>
using namespace std;

int main(){

char buf[] = "192.168.12.1";
unsigned int num = 0;
inet_pton(AF_INET, buf, &num);
cout << num << endl; // 转成了整数

//创建一个IP字符串
char dst[16] = "";
const char * str = inet_ntop(AF_INET, &num, dst, sizeof(dst)); // dst为数组首地址
cout << str << endl; // 转成了IP字符串
return 0;
}
sockaddr数据结构
通用socket地址(只为IPv4设计)
#include <bits/socket.h>
struct sockaddr{
sa_family_t sa_family;/*地址族类型,本教程使用AF_INET,代表TCP/IPv4协议族*/
char sa_data[14]; /*14字节,存放socket地址值,ip地址和端口号*/
};
typedef unsigned short int sa_family_t;

// sa_family成员是地址族类型(sa_family_t)的变量。地址族类型通常与协议族类型对应。常见的协议族和对应的地址族如下
/*
协议族(domain) 地址族 描述
PF_UNIX AF_UNIX UNIX本地域协议族
PF_INET AF_INET TCP/IPv4协议族
PF_INET6 AF_INET6 TCP/IPv6协议族
*/
专用socket地址

作为参数使用时要强转成sockaddr类型

image-20221112140046029

struct sockaddr_in{
sa_family_t sin_family;//地址族:AF_INET
u_int16_t sin_port;//端口号,要用网络字节序表示
struct in_addr sin_addr;//IPV4地址结构体
}
struct in_addr {
u_int32_t s_addr;//ipv4地址,要用网络字节序
}

TCP通信流程

img

socket通信分两部分:服务器端与客户端

//服务器端(被动接受连接的角色)
1.创建一个用于监听的套接字【lfd】
-监听:监听有客户端的连接
-套接字:这个套接字其实就是一个文件描述符
2.将这个监听文件描述符和本地的IP和端口绑定(IP和端口就是服务器的地址信息)【【saddr要设置IP、端口、协议】lfd与saddr绑定】
-客户端连接服务器的时候使用的就是这个IP和端口
3.设置监听,监听的fd开始工作
4.阻塞等待,当有客户端发起连接,解除阻塞,接受客户端的连接,会得到一个新的和客户端通信的套接字【clientaddr只要定义, cfd【cfd与clientaddr通信】】
5.通信
-接收数据
-发送数据
6.通信结束,断开连接
//客户端
1.创建一个用于通信的套接字【clientfd】
2.连接服务器,需要指定连接的服务器的IP和端口【serveraddr要设置IP、端口、协议【clientfd与serveraddr连接,用clientfd通信】】
3.连接成功了,客户端可以直接和服务器通信
-接收数据
-发送数据
4.通信结束,断开连接
image-20221112154950130

socket函数

要包含的头文件
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h> // 若包含了这个,上面两个可以省略
socket

int socket(int domain, int type, int protocol); 创建一个套接字

domain 协议族

​ AF_INET : ipv4
​ AF_INET6 : ipv6
​ AF_UNIX,AF_LOCAL :本地套接字通信(进程间通信)

type 通信过程中使用的协议类型

​ SOCK_STREAM : 流式协议
​ SOCK_DGRAM : 报式协议

protocol 具体的一个协议。一般写0

​ SOCK_STREAM : 流式协议默认使用TCP
​ SOCK_DGRAM : 报式协议默认使用UDP

返回值 成功:返回文件描述符,操作的就是内核缓冲区 失败: -1

bind

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); 绑定,将fd和本地的IP +端口进行绑定

sockfd 通过socket函数得到的文件描述符

addr 需要绑定的socket地址, 这个地址封装了ip和端口号的信息

addrlen 第二个参数结构体占的内存大小

listen

int listen(int sockfd, int backlog); 监听这个socket上的连接

sockfd 通过socket ()函数得到的文件描述符

backlog 未连接的和已经连接的和的最大值,给5就行 不能超过/proc/sys/net/core/somaxconn的值

accept

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); 阻塞的函数,阻塞等待客户端连接

sockfd 用于监听的文件描述符

addr 传出参数,记录了连接成功后客户端的地址信息(ip, port)

addrlen 指定第二个参数的对应的内存大小

返回值 成功返回用于通信的文件描述符,失败返回-1

connect

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen); 客户端连接服务器

sockfd 用于通信的文件描述符

addr 客户端要连接的服务器的地址信息

addrlen 第二个参数的内存大小

write

ssize_t write(int fd, const void *buf, size_t count);

(send)
char recvBuf[1024] = "hello";
int ret = send( cfd, recvBuf, strlen(recvBuf) + 1, 0);
read

ssize_t read(int fd, void *buf, size_t count);

(recv)
char recvBuf[1024] = {0};
int len = recv(cfd, recvBuf, sizeof(recvBuf), 0);
if(len == -1) {
perror("recv");
return -1;
}else if(len == 0){
printf("客户端已经断开连接...\);
break ;
}else if(len > 0){
printf("read buf = %s\n", recvBuf);
}
* fgets
char sendBuf[1024] = {0};
fgets(sendBuf, sizeof(sendBuf), stdin); // 阻塞,接收用户输入,再写入sendBuf
write(fd, sendBuf, strlen( sendBuf) + 1);

代码实现服务器/客户端通信

//TCP通信的服务器端
/*
1.创建一个用于监听的套接字(lfd)
-监听:监听有客户端的连接
-套接字:这个套接字其实就是一个文件描述符
2.将这个监听文件描述符和本地的IP和端口绑定(IP和端口就是服务器的地址信息)
-客户端连接 服务器的时候使用的就是这个IP和端口
3.设置监听,监听的fd开始工作
4.阻塞等待,当有客户端发起连接,解除阻塞,接受客户端的连接,会得到一个新的和客户端通信的套接字(cfd)
5.通信
-接收数据
-发送数据
6.通信结束,断开连接
*/
#include<stdio.h>
#include <arpa/inet.h>
#include<unistd.h>
#include<iostream>
#include<string.h>
#include<stdlib.h>

using namespace std;
int main(){
// 1.socket()
int listenfd = socket(AF_INET, SOCK_STREAM, 0);

if(listenfd == -1){
perror("socket");
exit(-1);
}

// 2.bind()
struct sockaddr_in saddr;
saddr.sin_family = AF_INET; // 网络协议
// inet_pton(AF_INET, "192.168.12.1", &saddr.sin_addr.s_addr); // IP
saddr.sin_addr.s_addr = INADDR_ANY; // 服务器开发时可写,表示服务器端任何IP都可以被客户端访问
saddr.sin_port = htons(9999); // 端口
int ret = bind(listenfd, (struct sockaddr*)&saddr, sizeof(saddr));

if(ret == -1){
perror("bind");
exit(-1);
}

// 3.listen()
ret = listen(listenfd, 8); // 8为连接数

if(ret == -1){
perror("listen");
exit(-1);
}

// 4.accept()
struct sockaddr_in clientaddr;
int len = sizeof(clientaddr);
int cfd = accept(listenfd, (sockaddr*)&clientaddr, (socklen_t *)len);

if(cfd == -1){
perror("accept");
exit(-1);
}

// 输出客户端的信息
char clientIP[16];
inet_ntop(AF_INET, &clientaddr.sin_addr.s_addr, clientIP, sizeof(clientIP));
unsigned short clientPort = ntohs(clientaddr.sin_port);
cout << "client ip:" << clientIP << ", port:" << clientPort << endl;

// 5. recv() & send()
// 获取客户端的数据
char recvBuf[1024] = {0};
int len1 = read(cfd, recvBuf, sizeof (recvBuf)) ;
if(len1 == -1){
perror("read");
exit(-1);
} else if(len1 > 0){
printf("recv client data : %s\n", recvBuf);
} else if(len1 == 0){
//表示客户端断开连接
printf("clinet closed...");
}
// 给客户端发送数据
char * data = "hello,i am server";
write(cfd, data, strlen(data));

// 6.关闭文件描述符
close(cfd);
close(listenfd);

return 0;
}
//TCP通信的客户端
/*
1.创建一个用于通信的套接字(clientfd)
2.连接服务器,需要指定连接的服务器的IP和端口
3.连接成功了,客户端可以直接和服务器通信
-接收数据
-发送数据
4.通信结束,断开连接
*/
#include<stdio.h>
#include <arpa/inet.h>
#include<unistd.h>
#include<iostream>
#include<string.h>

int main(){
// 1.socket()
int clientfd = socket(AF_INET, SOCK_STREAM, 0);

if(clientfd == -1){
perror("socket");
exit(-1);
}

// 2.connect()
struct sockaddr_in serveraddr;
serveraddr.sin_family = AF_INET;
inet_pton(AF_INET, "172.16.208.128", &serveraddr.sin_addr.s_addr);
serveraddr.sin_port = htons(9999); // 两个端口要一致
int ret = connect(clientfd, (sockaddr *)&serveraddr, sizeof(serveraddr));

if(ret == -1){
perror("connect");
exit(-1);
}

// 3. recv() & send()
// 给服务器端发送数据
char * data = "hello,i am client";
write(clientfd, data, strlen(data));
// 获取服务器端的数据
char recvBuf[1024] = {0};
int len = read(clientfd, recvBuf, sizeof (recvBuf));
if(len == -1){
perror("read");
exit(-1);
} else if(len > 0){
printf("recv server data : %d\n", recvBuf);
} else if(len == 0){
//表示服务器断开连接
printf("server closed...");
}

// 4.close()
close(clientfd);

return 0;
}

TCP三次握手 & 四次挥手

三次握手(发生在客户端connect()中)
img

1)第一次握手:建立连接时,客户端向服务器发送SYN包(seq=x),请求建立连接,等待确认

2)第二次握手:服务端收到客户端的SYN包,回一个ACK包(ACK=x+1)确认收到,同时发送一个SYN包(seq=y)给客户端

3)第三次握手:客户端收到SYN+ACK包,再回一个ACK包(ACK=y+1)告诉服务端已经收到

4)三次握手完成,成功建立连接,开始传输数据

四次握手(发生在两端close()中)
img

1)客户端发送FIN包(FIN=1)给服务端,告诉它自己的数据已经发送完毕,请求终止连接,此时客户端不发送数据,但还能接收数据

2)服务端收到FIN包,回一个ACK包给客户端告诉它已经收到包了,此时还没有断开socket连接,而是等待剩下的数据传输完毕

3)服务端等待数据传输完毕后,向客户端发送FIN包,表明可以断开连接

4)客户端收到后,回一个ACK包表明确认收到,等待一段时间,确保服务端不再有数据发过来,然后彻底断开连接

TCP拥塞控制

  1. 滑动窗口协议是传输层进行流控的一种措施,接收方通过通告发送方自己的窗口大小,从而控制发送方的发送速度,从而达到防止发送方发送速度过快而导致自己被淹没的目的

    TCP的滑动窗口解决了端到端的流量控制、ACK确认、拥塞控制 问题,允许接受方对传输进行限制,直到它拥有足够的缓冲空间来容纳更多的数据

  2. TCP协议要求维护以下两个端口:

    1. 接收窗口rwnd,接收方根据目前接收缓存大小所许诺的最新窗口值,反映接收方的容量( 由接收方根据其放在TCP报文的首部的窗口字段通知发送方)
    2. 拥塞窗口cwnd,发送方根据自己估算的网络拥塞程度而设置的窗口值,反映网络的当前容量。只要网络未出现拥塞,拥塞窗口就再增大一些,以便把更多的分组发送出去。但只要网络出现拥塞,拥塞窗口就减小一些,以减少注入网络的分组数。
    • 发送窗口的上限值应取接收窗口rwnd和拥塞窗口cwnd中较小的一个 发送窗口的上限值=min{rwnd,cwnd}
发送方如何维护拥塞窗口
慢开始和拥塞避免
img
快重传和快恢复
img
(补充)TCP两种重传方式

TCP在发送数据时会设置一个计时器,若到计时器超时仍未收到数据确认信息,则会引发相应的超时或基于计时器的重传操作,计时器超时称为超时重传

另一种方式的重传称为快速重传,通常发生在没有延时的情况下。若TCP累积确认无法返回新的ACK,或者当ACK包含的选择确认信息(SACK)表明出现失序报文时,快速重传会推断出现丢包,需要重传

TCP通信并发

多进程实现并发服务器
第一版
/*
思路:
1.一个父进程,多个子进程
2.父进程: 负责等待并接受客户端的连接
3.子进程: 完成通信,接受一个客户端连接,就创建一个子进程用于通信
*/
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>
#include <string.h>
#include <stdlib.h>

using namespace std;
int main(){

// 1. socket()
int lfd = socket(AF_INET, SOCK_STREAM, 0);

if(lfd == -1){
perror("socket");
exit(-1);
}

// 2. bind()
sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_addr.s_addr = INADDR_ANY;
saddr.sin_port = htons(9999);
int ret = bind(lfd, (sockaddr*)&saddr, sizeof(saddr));

if(ret == -1){
perror("bind");
exit(-1);
}

// 3. listen()
ret = listen(lfd, 8);

if(ret == -1){
perror("listen");
exit(-1);
}

// 4. accept() 并创建子进程
while(1){
sockaddr_in clientaddr;
socklen_t clientlen = sizeof(clientaddr);
int cfd = accept(lfd, (sockaddr*)&clientaddr, &clientlen);

if(cfd == -1){
perror("accept");
exit(-1);
}

// 每一个连接进来,就创建一个子进程和客户端通信
pid_t pid = fork();
if(pid == 0){ // pid == 0 为子进程
// 输出客户端信息
char clientIP[16];
inet_ntop(AF_INET, &clientaddr.sin_addr.s_addr, clientIP, sizeof(clientIP));
unsigned short clientPort = ntohs(clientaddr.sin_port);
cout << "client ip:" << clientIP << ", port:" << clientPort << endl;

// 5. 通信
char recvBuf[1024] = {0};
while(1){
// 接收消息
int len = read(cfd, recvBuf, sizeof (recvBuf)) ;
if(len == -1){
perror("read");
exit(-1);
} else if(len > 0){
printf("recv client data : %s\n", recvBuf);
} else if(len == 0){
//表示客户端断开连接
printf("clinet closed...");
}
// 发送数据
char * data = "hello,i am server";
write(cfd, data, strlen(data));
}

close(cfd);
// exit(0); 可写可不写
}
}

// 6. close()
close(lfd);

return 0;
}
// 客户端
#include<stdio.h>
#include <arpa/inet.h>
#include<unistd.h>
#include<iostream>
#include<string.h>
#include<stdlib.h>

int main(){

// 1.socket()
int clientfd = socket(AF_INET, SOCK_STREAM, 0);

if(clientfd == -1){
perror("socket");
exit(-1);
}


// 2.connect()
struct sockaddr_in serveraddr;
serveraddr.sin_family = AF_INET;
inet_pton(AF_INET, "172.16.208.128", &serveraddr.sin_addr.s_addr);
serveraddr.sin_port = htons(9999); // 两个端口要一致
int ret = connect(clientfd, (sockaddr *)&serveraddr, sizeof(serveraddr));

if(ret == -1){
perror("connect");
exit(-1);
}


// 3. recv() & send()
char recvBuf[1024] = {0};
int i = 0;
while(1){
// 给服务器端发送数据
sprintf(recvBuf, "data: %d\n", i ++);
write(clientfd, recvBuf, strlen(recvBuf));
sleep(1);

// 获取服务器端的数据
int len = read(clientfd, recvBuf, sizeof (recvBuf)) ;
if(len == -1){
perror("read");
exit(-1);
} else if(len > 0){
printf("recv server data : %s\n", recvBuf);
} else if(len == 0){
//表示服务器断开连接
printf("server closed...");
}
}

// 4.close()
close(clientfd);

return 0;
}
第二版(回收子进程资源)
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>
#include <string.h>
#include <stdlib.h>
#include <signal.h>
#include <wait.h>
#include <errno.h>

using namespace std;

void recycleChild(int arg){
while(1){
int ret = waitpid(-1, NULL, WNOHANG); // -1代表回收所有子进程, WNOHANG代表非阻塞
if(ret == -1){
//所有的子进程都回收了
break;
}else if(ret == 0) {
//还有子进程活着
break;
}else if(ret > 0){
//被回收了
cout << "子进程" << ret << "被回收了" << endl;
}
}
}

int main(){
// 0. 注册信号捕捉, 目的是回收资源
struct sigaction act;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
act.sa_handler = recycleChild; // 回调函数
sigaction(SIGCHLD, &act, NULL);

// 1. socket()
int lfd = socket(AF_INET, SOCK_STREAM, 0);

if(lfd == -1){
perror("socket");
exit(-1);
}

// 2. bind()
sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_addr.s_addr = INADDR_ANY;
saddr.sin_port = htons(9999);
int ret = bind(lfd, (sockaddr*)&saddr, sizeof(saddr));

if(ret == -1){
perror("bind");
exit(-1);
}

// 3. listen()
ret = listen(lfd, 8);

if(ret == -1){
perror("listen");
exit(-1);
}

// 4. accept() 并创建子进程
while(1){
sockaddr_in clientaddr;
socklen_t clientlen = sizeof(clientaddr);
int cfd = accept(lfd, (sockaddr*)&clientaddr, &clientlen);

if(cfd == -1){
if(errno == EINTR){
continue;
}
perror("accept");
exit(-1);
}

// 每一个连接进来,就创建一个子进程和客户端通信
pid_t pid = fork();
if(pid == 0){ // pid == 0 为子进程
// 输出客户端信息
char clientIP[16];
inet_ntop(AF_INET, &clientaddr.sin_addr.s_addr, clientIP, sizeof(clientIP));
unsigned short clientPort = ntohs(clientaddr.sin_port);
cout << "client ip:" << clientIP << ", port:" << clientPort << endl;

// 5. 通信
char recvBuf[1024] = {0};
while(1){
// 接收消息
int len = read(cfd, recvBuf, sizeof (recvBuf)) ;
if(len == -1){
perror("read");
exit(-1);
} else if(len > 0){
printf("recv client data : %s\n", recvBuf);
} else if(len == 0){
//表示客户端断开连接
cout << "client closed..." << endl;
break; // 不break的话会继续发送一份数据
}

// 发送数据
write(cfd, recvBuf, strlen(recvBuf));
}

close(cfd);
// exit(0); 可写可不写
}
}

// 6. close()
close(lfd);

return 0;
}
#include<stdio.h>
#include <arpa/inet.h>
#include<unistd.h>
#include<iostream>
#include<string.h>
#include<stdlib.h>
using namespace std;

int main(){
// 1.socket()
int clientfd = socket(AF_INET, SOCK_STREAM, 0);

if(clientfd == -1){
perror("socket");
exit(-1);
}

// 2.connect()
struct sockaddr_in serveraddr;
serveraddr.sin_family = AF_INET;
inet_pton(AF_INET, "172.16.208.128", &serveraddr.sin_addr.s_addr);
serveraddr.sin_port = htons(9999); // 两个端口要一致
int ret = connect(clientfd, (sockaddr *)&serveraddr, sizeof(serveraddr));

if(ret == -1){
perror("connect");
exit(-1);
}

// 3. recv() & send()
char recvBuf[1024] = {0};
int i = 0;
while(1){
// 给服务器端发送数据
sprintf(recvBuf, "data: %d\n", i ++);
write(clientfd, recvBuf, strlen(recvBuf));

// 获取服务器端的数据
int len = read(clientfd, recvBuf, sizeof (recvBuf)) ;
if(len == -1){
perror("read");
exit(-1);
} else if(len > 0){
printf("recv server data : %s\n", recvBuf);
} else if(len == 0){
//表示服务器断开连接
cout << "server closed..." << endl;
// break;
}

sleep(1); // 只是为了教学方便
}

// 4.close()
close(clientfd);

return 0;
}
多线程实现并发服务器
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <iostream>
#include <string.h>
#include <stdlib.h>
#include <pthread.h>
using namespace std;

struct sockInfo{
int fd; // cfd
pthread_t tid; // 自己tid
sockaddr_in addr; // 客户端信息
};
sockInfo sockInfos[128]; // 最多有128个子线程

void * working(void * arg){
sockInfo* pinfo = (sockInfo* )arg;
// 5.子线程和客户端通信
// 输出客户端信息
char clientIP[16];
inet_ntop(AF_INET, &pinfo->addr.sin_addr.s_addr, clientIP, sizeof(clientIP));
unsigned short clientPort = ntohs(pinfo->addr.sin_port);
cout << "client ip:" << clientIP << ", port:" << clientPort << endl;

// 5. 通信
char recvBuf[1024] = {0};
while(1){
// 接收消息
int len = read(pinfo->fd, recvBuf, sizeof(recvBuf)) ;
if(len == -1){
perror("read");
exit(-1);
} else if(len > 0){
printf("recv client data : %s\n", recvBuf);
} else if(len == 0){
//表示客户端断开连接
cout << "client closed..." << endl;
break; // 不break的话会继续发送一份数据
}

// 发送数据
write(pinfo->fd, recvBuf, strlen(recvBuf));
}
close(pinfo->fd);

return NULL;
}

int main(){
// 1. socket()
int lfd = socket(AF_INET, SOCK_STREAM, 0);

if(lfd == -1){
perror("socket");
exit(-1);
}

// 2. bind()
sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_addr.s_addr = INADDR_ANY;
saddr.sin_port = htons(9999);
int ret = bind(lfd, (sockaddr*)&saddr, sizeof(saddr));

if(ret == -1){
perror("bind");
exit(-1);
}

// 3. listen()
ret = listen(lfd, 8);

if(ret == -1){
perror("listen");
exit(-1);
}

// 初始化数据
int max = sizeof(sockInfos) / sizeof(sockInfos[0]);
for(int i = 0; i < max; i ++){
bzero(&sockInfos[i], sizeof(sockInfos[i]));
sockInfos[i].fd = -1;
sockInfos[i].tid = -1;
}

// 4. accept() 并创建子线程
while(1){
sockaddr_in clientaddr;
socklen_t clientlen = sizeof(clientaddr);
int cfd = accept(lfd, (sockaddr*)&clientaddr, &clientlen);

// *
struct sockInfo * pinfo;
for(int i = 0; i < max; i ++){
//从这个数组I找到一个可以用的sockInfo元素
if(sockInfos[i].fd == -1){
pinfo = &sockInfos[i];
break;
}
if(i == max - 1){
sleep(1);
i--;
}
}
pinfo -> fd = cfd;
memcpy(&pinfo -> addr, &clientaddr, sizeof(clientaddr));

// 创建子线程
pthread_create(&pinfo -> tid, NULL, working, pinfo);

// pthread_join() 不能用,因为它是阻塞的。如果子线程不死亡,主线程则会一直卡在此处,不会执行 while 循环
pthread_detach(pinfo -> tid); // 设置线程分离
}

// 6.close()
close(lfd);

return 0;
}

TCP半关闭、端口复用

shutdown

int shutdown(int sockfd, int how);

当TCP链接中A向B发送FIN请求关闭,另一端B回应ACK之后(A端进入FIN_WAIT2状态),并没有立即发送FIN给A, A处于半连接状态(半开关),此时A可以接收B发送的数据,但是A已经不能再向B发送数据。从程序的角度,可以使用API来控制实现半连接状态:

#include <sys/socket.h>
int shutdown(int sockfd, int how);
sockfd: 需要关闭的socket的描述符
how: 允许为shutdown操作选择以下几种方式:
SHUT_RD(0): 关闭sdckfd上的读功能,此选项将不允许sockfd进行读操作
该套接字不再接收数据,任何当前在套接字接受缓冲区的数据将被无声的丢弃掉
SHUT_WR(1): 关闭sockfd的写功能,此选项将不允许sockfd进行写操作。进程不能在对此套接字发出写操作
SHUT_RDWR(2): 关闭sockfd的读写功能。相当于调用shut down两次:首先是以SHUT_RD ,然后以SHUT_WR

使用close中止一个连接,但它只是减少描述符的引用计数,并不直接关闭连接,只有当描述符的引用计数为0时才关闭连接。shutdown 不考虑描述符的引用计数,直接关闭描述符。也可选择中止一个方向的连接,只中止读或只中止写

注意:

  1. 如果有多个进程共享一个套接字, close 每被调用一次,计数减1,直到计数为0时,也就是所用进程都调用了close,套接字将被释放

  2. 在多进程中如果一个进程调用了shutdown(sfd, SHUT_RDWR)后,其它的进程将无法进行通信。但如果一个进程close(sfd)将不会影响到其它进程

setsockopt

设置端口复用(也可以设置socket的其他属性):

  1. 防止服务器重启时之前绑定的端口还未释放
  2. 防止程序突然退出而系统没有释放端口
image-20221116151457850
#include <sys/types.h>
#include <sys/socket.h>
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
参数:
- sockfd: 要操作的文件描述符
- level: 级别
SOL_SOCKET (端口复用的级别)
- optname: 选项的名称
SO_REUSEADDR
SO_REUSEPORT
-optval: 端口复用的值(端口复用中为整型)
1: 可以复用
0: 不可以复用
-optlen: optva1参数的大小

端口复用,设置的时机是在服务器绑定端口之前。
setsockopt();
bind();
netstat

查看网络相关信息的命令

参数:
-a 所有的socket
-p 显示正在使用socket的程序的名称
-n 直接使用IP地址,而不通过域名服务器

IO多路复用(转接)

I: 输入 指数据由 程序(文件) -> 内存

O: 输出 指数据由 内存 -> 程序(文件)

I/O多路复用使得程序能同时监听多个文件描述符(在此之前,若有多个客户端同时请求,我们只能在while循环中依次监听),能够提高程序的性能, Linux 下实现I/O多路复用的系统调用主要有select, poll 和epoll

几种常见的I/O模型
BIO
IMG_7786
NIO
IMG_7787 IMG_7788

解决NIO的方法:IO多路复用

IMG_7790 IMG_7791
select
  1. 首先要构造一个关于文件描述符的列表,将要监听的文件描述符添加到该列表中。
  2. 调用一个系统函数,监听该列表中的文件描述符,直到这些描述符中的一个或者多个进行I/O操作时,该函数才返回。
    • 函数对文件描述符的检测的操作是由内核完成的
    • 在返回时,它会告诉进程有多少(哪工)描述符要进行I/O操作
#include <sys/time.h>
#include <sys/types.h>
#include <sys/select.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds , fd_set *exceptfds, struct timeval *timeout);
-参数:
- nfds 委托内核检测的最大文件描述符的值 + 1
- readfds 要检测的文件描述符的读的集合,委托内核检测哪些文件描述符的读的属性
- 一般检测读操作
- 对应的是对方发送过来的数据,因为读是被动的接收数据,检测的就是读缓冲区
- 是一个传入传出参数
- writefds 要检测的文件描述符的写的集合,委托内核检测哪些文件描述符的写的属性
- 委托内核检测写缓冲区是不是还可以写数据(不满的就可以写)
- exceptfds :检测发生异常的文件描述符的集合
- timeout 设置的超时时间
struct timeva1 {
long tv_ sec; /* seconds */
long tv_ _usec; /* microseconds */
};
- NULL 永久阻塞,直到检测到了文件描述符有变化
- tv_sec = 0 tv_usec = 0,不阻塞
- tv_sec > 0 tv_usec > 0,阻塞对应的时间
-返回值:
- -1 失败
- >0 (n) 检测的集合中有n个文件描述符发生了变化

void FD_CLR(int fd, fd_set *set); //将参数文件描述符fd对应的标志位设置为0
int FD_ISSET(int fd, fd_set *set); //判断fd对应的标志位是0还是1,返回值 : fd对应的标志位的值, 0, 返回0,1, 返回1
void FD_SET(int fd, fd_set *set); //将参数文件描述符fd对应的标志位,设置为1
void FD_ZERO(fd_set *set); // fd_set共有1024 bit,全部初始化为0
select工作流程
image-20221117131507683
select代码实现
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/select.h>
#include <iostream>
using namespace std;

int main(){
// 1. socket()
int lfd = socket(AF_INET, SOCK_STREAM, 0);

if(lfd == -1){
perror("socket");
exit(-1);
}

// 2. bind()
sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_addr.s_addr = INADDR_ANY;
saddr.sin_port = htons(9999);
int ret = bind(lfd, (sockaddr*)&saddr, sizeof(saddr));

if(ret == -1){
perror("bind");
exit(-1);
}

// 3. listen()
ret = listen(lfd, 8);

if(ret == -1){
perror("listen");
exit(-1);
}

// 创建一个fd_set的集合,存放的是需要检测的文件描述符
fd_set rdset, temp; // rdset是用户自己维护的, temp是交给内核去修改的
FD_ZERO(&rdset); //初始化,全置0
FD_SET(lfd, &rdset); //将参数文件描述符fd对应的标志位,设置为1
int maxfd = lfd;

while(1){
temp = rdset;
// 调用select,让内核检测那些文件描述符有数据
int ret = select(maxfd + 1, &temp, NULL, NULL, NULL);
if(ret == -1){
perror("select");
exit(-1);
}else if(ret == 0){ // 这里我们设置的timeval为NULL,所以是阻塞型,ret不可能返回0
continue;
}else if(ret > 0){ // 说明检测到了有文件描述符的对应的缓冲区的数据发生了改变
if(FD_ISSET(lfd, &temp)){
// 判断fd对应的标志位是0还是1 , 为1代表有新的客户端连接进来了
sockaddr_in clientaddr;
socklen_t clientlen = sizeof(clientaddr);
int cfd = accept(lfd, (sockaddr*)&clientaddr, &clientlen);

// 将新的文件描述符加入到set中
FD_SET(cfd, &rdset);
maxfd = maxfd > cfd ? maxfd : cfd;
}

for(int i = lfd + 1; i <= maxfd; i ++){ // lfd最先被监听,肯定在最前面
if(FD_ISSET(i, &temp)){ // 说明该文件描述符对应的客户端发来了数据
char buf[1024] = {0};
int len = read(i, buf, sizeof(buf));
if(len == -1){
perror("read");
exit(-1);
}else if(len == 0){
cout << "client closed..." << endl;
close(i); // close(cfd)
FD_CLR(i, &rdset);
}else if(len > 0){
cout << "read buf =" << buf << endl;
write(i, buf, strlen(buf) + 1);
}
}
}
}
}

close(lfd);

return 0;
}
select缺点
  1. 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
  2. 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
  3. select支持的文件描述符数量太小了默认是1024
  4. fds集合不能重用,每次都需要重置
poll
#include <po11.h>
struct po11fd{
int fd; /*委托内核检测的文件描述符*/
short events; /*委托内核检测文件描述符的什么事件*/
short revents; /*内核返回文件描述符实际发生的事件*/
};
int po11(struct po11fd *fds, nfds_t nfds, int timeout);
-参数:
-fds 是一个struct pol1fd结构体数组,这是一个需要检测的文件描述符的集合
-nfds 这个是第一个参数数组中最后一个有效元素的下标 + 1
-timeout 阻塞时长
0 不阻塞
-1 阻塞,当检测到需要检测的文件描述符有变化,解除阻塞
>0 阻塞的时长
-返回值:
-1 失败
>0 (n) 成功,n表示检测到集合中有n个文件描述符发生变化

image-20221117145127724

poll代码实现
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <poll.h>>
#include <iostream>
using namespace std;

int main(){
// 1. socket()
int lfd = socket(AF_INET, SOCK_STREAM, 0);

if(lfd == -1){
perror("socket");
exit(-1);
}

// 2. bind()
sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_addr.s_addr = INADDR_ANY;
saddr.sin_port = htons(9999);
int ret = bind(lfd, (sockaddr*)&saddr, sizeof(saddr));

if(ret == -1){
perror("bind");
exit(-1);
}

// 3. listen()
ret = listen(lfd, 8);

if(ret == -1){
perror("listen");
exit(-1);
}

// 初始化检测的文件描述符数组
struct pollfd fds[1024];
for(int i = 0; i < 1024; i ++){
fds[i].fd = -1;
fds[i].events = POLLIN; //需要检测读事件
}
fds[0].fd = lfd;
int nfds = 0; // 这里就是最大索引,而不是最大索引 + 1

while (1){
// 调用 poll,让内核检测那些文件描述符有数据
int ret = poll(fds, nfds + 1, -1); // -1表示阻塞
if(ret == -1){
perror("select");
exit(-1);
}else if(ret == 0){
continue;
}else if(ret > 0){ // 说明检测到了有文件描述符的对应的缓冲区的数据发生了改变
if(fds[0].revents & POLLIN){ // 有新客户端连接进来了,因为revents 返回的是 POLLIN | POLLOUT
sockaddr_in clientaddr;
socklen_t clientlen = sizeof(clientaddr);
int cfd = accept(lfd, (sockaddr*)&clientaddr, &clientlen);

// 将cfd加入到监听数组
for(int i = 1; i < 1024; i ++){ // 0是lfd
if(fds[i].fd == -1){ // fds[i]可用
fds[i].fd = cfd;
fds[i].events = POLLIN;
break;
}
}

//更新最大的文件描述符索引
nfds = nfds > cfd ? nfds : cfd;
}

for(int i = 1; i <= nfds; i ++){ // lfd最先被监听,为0
if(fds[i].revents & POLLIN){ // 说明该文件描述符对应的客户端发来了数据
char buf[1024] = {0};
int len = read(fds[i].fd, buf, sizeof(buf));
if(len == -1){
perror("read");
exit(-1);
}else if(len == 0){
cout << "client closed..." << endl;
close(i); // close(cfd)
fds[i].fd = -1;
}else if(len > 0){
cout << "read buf =" << buf << endl;
write(fds[i].fd, buf, strlen(buf) + 1);
}
}
}
}
}

close(lfd);

return 0;
}
poll缺点
  1. 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大

  2. 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大

  3. select支持的文件描述符数量太小了默认是1024

  4. fds集合不能重用,每次都需要重置

epoll
#include <sys/epoll.h>
//创建一个新的epo11实例。在内核中创建了一个数据,这个数据中有两个比较重要的数据,一个是需要检测的文件描述符的信息(红黑树),还有一个是就绪列表,存放检测到数据发生改变的文件描述符信息(双向链表)。
int epo11_create(int size);
-参数:
size :目前没有意义了。随便写一个数,必须大于0
-返回值:
-1 失败
>0 文件描述符,操作epo11实例的
typedef union epo11_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;

struct epo11_event {
uint32_t events; /* Epo11 events */
epo11_data_t data; /* User data variable */
}

//对epoll实例进行管理:添加文件描述符信息,删除信息,修改信息
int epo11_ct1(int epfd, int op, int fd, struct epo11_event *event);
-参数:
epfd epo11实例对应的文件描述符
op 要进行什么操作
EPOLL_CTL_ADD:添加
EPOLL_CTL_MOD:修改
EPOLL_CTL_DEL :删除
fd 要检测的文件描述符
event 检测文件描述符什么事情
EPOLLIN
EPOLLOUT
EPOLLERR

//检测函数
int epo11_wait(int epfd, struct epo11_event *events, int maxevents, int timeout);
参数:
epfd epo11实例对应的文件描述符
events 传出参数,数组,保存了发生了变化的文件描述符的信息,
maxevents 第二个参数结构体数组的大小
timeout 阻塞时间
0:不阻塞
-1 :阻塞,直到检测到fd数据发生变化,解除阻塞
>0:阻塞的时长(毫秒)
返回值:
成功 返回发送变化的文件描述符的个数
失败 -1

image-20221117172543560

epoll代码实现
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>>
#include <iostream>
using namespace std;


int main(){
// 1. socket()
int lfd = socket(AF_INET, SOCK_STREAM, 0);

if(lfd == -1){
perror("socket");
exit(-1);
}

// 2. bind()
sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_addr.s_addr = INADDR_ANY;
saddr.sin_port = htons(9999);
int ret = bind(lfd, (sockaddr*)&saddr, sizeof(saddr));

if(ret == -1){
perror("bind");
exit(-1);
}

// 3. listen()
ret = listen(lfd, 8);

if(ret == -1){
perror("listen");
exit(-1);
}

// 调用epoll_create()创建一个epoll实例
int epfd = epoll_create(1);
//将监听的文件描述符相关的检测信息加入到epoll实例中
epoll_event epev;
epev.events = EPOLLIN; // 要检测他的读事件
epev.data.fd = lfd; // 监听的文件描述符
epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &epev);

epoll_event epevs[1024]; // 内核检测后会将已就绪的文件描述符放在这里面
while(1){
int ret = epoll_wait(epfd, epevs, 1024, -1); // -1设置阻塞。只有设置了阻塞时,会返回0,代表超时了都没有检测到变化的文件描述符
if(ret == -1){
perror("epoll_wait");
exit(-1);
}

for(int i = 0; i < ret; i ++){
if(epevs[i].data.fd == lfd) { // 监听到了客户端的连接
sockaddr_in clientaddr;
socklen_t clientlen = sizeof(clientaddr);
int cfd = accept(lfd, (sockaddr*)&clientaddr, &clientlen);

epev.events = EPOLLIN | EPOLLOUT;
epev.data.fd = cfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &epev); // 添加到epoll实例中
}else{ // epevs[i].data.fd == cfd 有数据到达,通信
if(epevs[i].events & EPOLLOUT){
continue;
}
char buf[1024] = {0};
int len = read(epevs[i].data.fd, buf, sizeof(buf));
if(len == -1){
perror("read");
exit(-1);
}else if(len == 0){
cout << "client closed..." << endl;
epoll_ctl(epfd, EPOLL_CTL_DEL, epevs[i].data.fd, NULL); // 将此fd从红黑树中删除
close(epevs[i].data.fd); // close(cfd)
}else if(len > 0){
cout << "read buf =" << buf << endl;
write(epevs[i].data.fd, buf, strlen(buf) + 1);
}
}
}
}

close(lfd);
close(epfd);

return 0;
}
epoll两种工作模式
  • LT模式(水平触发)

    假设委托内核检测读事件 -> 检测fd的读缓冲区->读缓冲区有数据 -> epoll检测到了会给用户通知

    1. 用户不读数据,数据一直在缓冲区,epoll 会一直通知
    2. 用户只读了一部分数据, epoll会通知
    3. 缓冲区的数据读完了,不通知

    LT (level - triggered)是缺省的工作方式,并且同时支持block和no-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行操作。如果你不作任何操作,内核还是会继续通知你的

  • ET模式(边沿触发)

    假设委托内核检测读事件->检测fd的读缓冲区->读缓冲区有数据-> epoll检测到了会给用户通知

    1. 用户不读数据,数据一致在缓冲区中,epoll下次检测的时候就不通知了
    2. 用户只读了一部分数据,epoll不通知
    3. 缓冲区的数据读完了,不通知

    ET (edge - triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epolI告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了。但是, 如果一直不对这个fd做操作, 内核不会发送更多的通知

    EPOLLONESHOT(相当于一个socket的锁)

    即使可以使用ET模式,一个socket上的某个事件还是可能被触发多次。这在并发程序中就会引起一个问题。 比如一个线程在读取完某个socket上的数据后开始处理这些数据,而在数据的处理过程中该socket上又有新数据可读. (EPOLIN再次被触发),此时另外一个线程被唤醒来读取这些新的数据。于是就出现了两个线程同时操作一个socket的局面。一个socket连接在任一时刻都只被一个线程处理, 可以使用epoll的 EPOLLONESHOT 事件实现

    对于注册了EPOLLONESHOT事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或者异常事件,且只触发一次,除非我们使用epoll_ ctl 函数重置该文件描述符上注册的EPOLLONESHOT事件。这样,当一个线程在处理某个socket时,其他线程是不可能有机会操作该socket的。但反过来思考,注册了EPOLLONESHOT事件的socket一旦被某个线程处理完毕,该线程就应该立即重置这个socket上的EPOLLONESHOT事件,以确保这个socket下一次可读时,其EPOLLIN事件能被触发,进而让其他工作线程有机会继续处理这个socket

UDP通信

UDP通信流程
image-20221118183329846
#include <sys/types.h>
#include <sys/socket.h>
ssize_t sendto(int sockfd, const void *buf, size_t len, int f1ags, const struct sockaddr *dest_addr, socklen_t addrlen);
-参数:
sockfd 通信的fd
buf 要发送的数据
1en 发送数据的长度
flags 写0就好
dest_addr :通信的另外一端的地址信息
addrlen 地址的内存大小

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
-参数:
sockfd 通信的fd
buf 接收数据的数组
len 数组的大小
flags 0
src_addr 用来保存另外一端的地址信息,不需要可以指定为NULL
addrlen 地址的内存大小
UDP通信代码实现
// UDP服务端
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <iostream>
using namespace std;


int main(){
// 1. socket()
int fd = socket(AF_INET, SOCK_STREAM, 0);

if(fd == -1){
perror("socket");
exit(-1);
}

// 2. bind()
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(9999);
int ret = bind(fd, (sockaddr*)&addr, sizeof(addr));

if(ret == -1){
perror("bind");
exit(-1);
}

// 3. 通信
while(1){
char recvbuf[128] = {0};
char ipbuf[16] = {0};
// 接收
sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
int num = recvfrom(fd, recvbuf, sizeof(recvbuf), 0, (sockaddr*)&clientaddr, &len);
if(num == -1){
perror("recvfrom");
exit(-1);
}
cout << "client IP :" << inet_ntop(AF_INET, &clientaddr.sin_addr.s_addr, ipbuf, sizeof(ipbuf)) << " Port : " << ntohs(clientaddr.sin_port) << endl;
cout << "client say :" << recvbuf << endl;

// 发送
sendto(fd, recvbuf, strlen(recvbuf) + 1, 0, (sockaddr*)&clientaddr, len);
}

close(fd);

return 0;
}
// UDP通信客户端
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <iostream>
using namespace std;


int main(){
// 1. socket()
int fd = socket(AF_INET, SOCK_STREAM, 0);

if(fd == -1){
perror("socket");
exit(-1);
}

// 2. bind()
sockaddr_in saddr;
saddr.sin_family = AF_INET;
// saddr.sin_addr.s_addr = INADDR_ANY;
saddr.sin_port = htons(9999);
inet_pton(AF_INET, "127.0.0.1", &saddr.sin_addr.s_addr);

// 3. 通信
int num = 0;
while(1){
char sendbuf[128] = {0};
sprintf(sendbuf, "hello, i am client: %d\n", num ++);
// 发送
sendto(fd, sendbuf, strlen(sendbuf) + 1, 0, (sockaddr*)&saddr, sizeof(saddr));

// 接收
int num = recvfrom(fd, sendbuf, sizeof(sendbuf), 0, (sockaddr*)&saddr, (socklen_t*)sizeof(saddr));
if(num == -1){
perror("recvfrom");
exit(-1);
}
cout << "server say :" << sendbuf << endl;
}

close(fd);

return 0;
}

广播

子网号:主机号(主机号全0表示该子网,全1代表在该子网中广播)

向子网中多台计算机发送消息,并且子网中所有的计算机都可以接收到发送方发送的消息,每个广播消息都包含一个特殊的IP地址,这个IP中子网内主机标志部分的二进制全部为1。

  1. 只能在局域网中使用
  2. 客户端(ABCD)需要绑定服务器(下图左)广播使用的端口,才可以接收到广播消息
image-20221118231053331
//设置广播属性的函数
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
sockfd : 文件描述符
level : SOL_SOCKET
optname : SO_BROADCAST
optval : int类型的值,为1表示允许广播
optlen : optval的大小
发送者:
(1) 创建套接字 -- 【fd】
(2) 设置为允许发送广播权限 -- setsockopt()
(3) 填充广播信息结构体 -- 【clientaddr设置IP(X.X.X.255)、端口 用来通信】
(4) 发送数据 -- sendto()

接受者:
(1) 创建套接字 -- 【fd】
(2) 填充广播信息结构体 -- 【addr设置IP(0.0.0.0)、端口 用来通信】
(3) 将套接字与广播信息结构体绑定 -- 【addr和fd】
(4) 接收数据 -- recvfrom()
// 发送端
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/socket.h>
#include<string.h>
#include<arpa/inet.h>
#include<net/if.h>
using namespace std;
#define CLIENT_PORT 9000
#define MAXLINE 4096
#define BROADCAST_IP "192.168.99.255"

int main(void){
// socket()
int fd = socket(AF_INET, SOCK_DGRAM, 0);

// 设置为允许发送广播权限
int op = 1;
setsockopt(fd , SOL_SOCKET , SO_BROADCAST , &op , sizeof(op));

// 构造client地址 IP+端口号
sockaddr_in clientaddr;
bzero(&clientaddr, sizeof(clientaddr));
clientaddr.sin_family = AF_INET;
inet_pton(AF_INET, BROADCAST_IP, &clientaddr.sin_addr.s_addr);
clientaddr.sin_port = htons(CLIENT_PORT);

// 通信
char buf[MAXLINE];
int i=0;
while(1){
sprintf(buf, "Drink %d glasses of water\n", i++);
sendto(fd, buf, strlen(buf), 0, (struct sockaddr *)&clientaddr, sizeof(clientaddr));
printf("%s",buf);
sleep(1);
}

close(fd);
return 0;
}
// 接收端
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <stdlib.h>
using namespace std;
#define MAXLINE 4096
#define CLIENT_PORT 9000

int main(){
//1.创建一个socket
int fd = socket(AF_INET, SOCK_DGRAM, 0);

//2.初始化本地端地址--IP地址本地,端口号9000
sockaddr_in addr;
bzero(&addr, sizeof(addr));
addr.sin_family = AF_INET;
inet_pton(AF_INET, "0.0.0.0" , &addr.sin_addr.s_addr);
addr.sin_port = htons(CLIENT_PORT);

int ret = bind(fd, (sockaddr *)&addr, sizeof(addr));
if(ret == -1){
perror("bind");
exit(-1);
}

char buf[MAXLINE];
while(1){
int len = recvfrom(fd, buf, sizeof(buf), 0, NULL, 0);
printf("%s",buf);
}

close(fd);
return 0;
}

组播

单播地址标识单个IP接口,广播地址标识某个子网的所有IP接口,多播地址标识一组IP接口。

单播和广播是寻址方案的两个极端(要么单个要么全部),多播则意在两者之间提供一种折中方案。 多播数据报只应该由对它感兴趣的接口接收,也就是说由运行相应多播会话应用系统的主机上的接口接收。另外,广播一般局限于局域网内使用,而多播则既可以用于局域网,也可以跨广域网使用

image-20221119030142283

组播地址

IP多播通信必须依赖于IP多播地址,在IPv4中它的范围从224.0.0.0 到239.255.255.255.并被划分为
局部链接多播地址、预留多播地址、管理权限多播地址

IP地址
244.0.0.0~244.0.0.255 局部链接多播地址:是为路由协议和其它用途保留的地址,路由器并不转发属于此范围的IP包
244.0.1.0~244.0.1.255 预留多播地址:公用组播地址,可用于Internet;使用前需要申请
244.0.2.0~238.255.255.255 预留多播地址:用户可用组播地址(临时组地址),全网范围内有效
239.0.0.0~239.255.255.255 本地管理组播地址,可供组织内部使用,类似于私有 IP 地址,不能用于 Internet,可限制多播范围
int setsockopt(int sockfd, int level,int optname, const void *optval, socklen_t optlen);

//服务器设置多播的信息,外出接口
- leve1 : IPPROTO_IP
- optname : IP_MULTICAST_IF
- optval : struct in_addr

//客户端加入到多播组:
- leve1 : IPPROTO_IP
- optname : IP_ADD_MEMBERSHIP
- optva1 : struct ip_mreq

struct ip_mreq{
struct in_addr imr_multiaddr; //组播的IP地址
struct in_addr imr_address; //本地的IP地址
};

typedef uint32_t in_addr_t;
struct in_addr{
in_addr_t s_addr;
};

在这里插入图片描述

发送者:
(1) 创建套接字 -- socket()
(2) 填充组播信息结构体 -- sockaddr_in
(3) 设置为组播属性 -- setsockopt()
(4) 发送数据 -- sendto()

接收者:
(1) 创建套接字 -- socket()
(2) 填充组播信息结构体 -- sockaddr_in
(3) 将套接字与组播信息结构体绑定 -- bind()
(4) 设置为加入多播组 -- setsockopt()
(5) 接收数据 -- recvfrom()
// 发送端
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/socket.h>
#include<string.h>
#include<arpa/inet.h>
#include<net/if.h>
using namespace std;
#define CLIENT_PORT 9000
#define MAXLINE 4096
#define MULTIIP "239.0.0.10"

int main(void){
// socket()
int fd = socket(AF_INET, SOCK_DGRAM, 0);

// 设置多播属性,设置外出接口
in_addr imr_multiaddr;
inet_pton(AF_INET, MULTIIP, &imr_multiaddr.s_addr); //初始化多播地址
setsockopt(fd, IPPROTO_IP, IP_MULTICAST_IF, &imr_multiaddr, sizeof(imr_multiaddr));

// 初始化客户端地址信息
sockaddr_in clientaddr;
bzero(&clientaddr, sizeof(clientaddr));
clientaddr.sin_family = AF_INET;
inet_pton(AF_INET, MULTIIP, &clientaddr.sin_addr.s_addr);
clientaddr.sin_port = htons(CLIENT_PORT);

// 通信
char buf[MAXLINE];
int i=0;
while(1){
sprintf(buf, "Drink %d glasses of water\n", i++);
sendto(fd, buf, strlen(buf), 0, (struct sockaddr *)&clientaddr, sizeof(clientaddr));
printf("%s",buf);
sleep(1);
}

close(fd);
return 0;
}
// 接收端
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <stdlib.h>
using namespace std;
#define MAXLINE 4096
#define CLIENT_PORT 9000
#define MULTIIP "239.0.0.10"

int main(){
//1.创建一个socket
int fd = socket(AF_INET, SOCK_DGRAM, 0);

//2.初始化本地端地址 IP地址本地,端口号9000
sockaddr_in addr;
bzero(&addr, sizeof(addr));
addr.sin_family = AF_INET;
inet_pton(AF_INET, "0.0.0.0", &addr.sin_addr.s_addr);
addr.sin_port = htons(CLIENT_PORT);

int ret = bind(fd, (sockaddr *)&addr, sizeof(addr));
if(ret == -1){
perror("bind");
exit(-1);
}

ip_mreq op;
inet_pton(AF_INET, MULTIIP, &op.imr_multiaddr.s_addr);
op.imr_interface.s_addr = INADDR_ANY;
// 加入到多播组
setsockopt(fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &op, sizeof(op));

char buf[MAXLINE];
while(1){
int len = recvfrom(fd, buf, sizeof(buf), 0, NULL, 0);
printf("%s",buf);
}

close(fd);
return 0;
}

本地套接字

本地套接字:同一主机的进程间通信,有没有关系的进程间通信都可以实现

采用TCP通信流程

image-20221119135550407
#include<sys/un.h>
#define UNIX_PATH_MAX 108
struct sockaddr_un{
__kernel_sa_family_t sun_family; // 地址族协议, AF_LOCAL
char sun_path[UNIX_PATH_MAX]; // 套接字文件的路径,这是一个伪文件,大小永远为0
};
//本地套接字通信的流程- tcp

//服务器端
1.创建监听的套接字
int lfd = socket(AF_UNIX/AF_LOCAL, SOCK_STREAM, 0);
2.监听的套接字绑定本地的套接字文件-> server端
struct sockaddr_un addr;
//绑定成功之后,指定的sun_ path中的套接字文件会自动生成。
bind(lfd, addr, len);
3.监听
listen(lfd,100);
4.等待并接受连接请求
struct sockaddr_un cliaddr ;
int cfd = accept(lfd, &cliaddr, len);
5.通信
接收数据: read/recv
发送数据: write/send
6.关闭连接
close();


//客户端
1.创建通信的套接字
int fd = socket(AF_UNIX/AF_LOCAL, SOCK_STREAM, 0);
2.监听的套接字绑定本地的IP端口
struct sockaddr_un addr ;
//绑定成功之后,指定的sun_ path中的套接字文件会自动生成。
bind(fd, addr, len);
3.连接服务器
struct sockaddr_un serveraddr ;
connect(fd,&serveraddr ,sizeof(serveraddr));
4.通信
接收数据: read/recv
发送数据: write/send
5.关闭连接
close();

项目实战与总结

阻塞和非阻塞、同步和异步

IO同步与进程同步不一样,IO同步是指自己操作数据,异步是指告诉内核要怎么做然后处理自己的事

image-20221120132531092

无论阻塞还是非阻塞,都是同步,只有调用了相关的API才是异步

image-20221120132728012

Unix、Linux上的五种IO模型

阻塞IO模型

进程发起IO系统调用后,进程被阻塞,转到内核空间处理,整个IO处理完毕后返回进程,操作成功则进程获取到数据

img
非阻塞IO模型

非阻塞等待,每隔一段时间就去检测IO事件是否就緒。没有就緒就可以做其他事。非阻塞IO执行系统调用总是立即返回,不管事件是否已经发生,若事件没有发生,则返回-1,此时可以根据errno区分这两种情况,对于accept, recv和send,事件末发生时,errno通常被设置成EAGAIN / EWOULDBLOCK

这种工作方式下需要不断轮询查看状态

img
多路复用

Linux用select/poll/epoll实现IO多路复用模型,这些函数也会使进程阻塞,但是和阻塞IO所不同的是这些函数可以同时阻塞多个IO操作。而且可以同时对多个读操作、写操作的IO函数进行检测。直到有数据可读或可写时,才真正调用IO操作函数

image-20221120135606806
信号驱动

Linux用工接口进行信号驱动IO,安装一个信号处理函数,进程继续运行并不阻塞,当10事件就绪,进程收到SIGIO信号,然后处理IO事件

image-20221120140459752
异步IO模型

当进程发起一个IO操作,进程返回(不阻塞),但也不能返回结果。内核把整个IO处理完后,会通知进程结果,如果IO操作成功则进程直接获取到数据

img
⭐️5种IO模型的区别

在这里插入图片描述

Web服务器简介及HTTP协议

服务器编程基本框架和两种高效的事件处理模式

虽然服务器程序种类繁多,但其基本框架都一样,不同之处在于逻辑处理

image-20221120175048248

模块 功能
I/O处理单元 处理客户连接,读写网络数据

逻辑单元 业务进程或线程

网络存储单元 数据库、文件或缓存

请求队列 各单元之间的通信方式(请求队列通常被实现为池的一部分)

两种高效的事件处理模式

服务器程序通常需要处理三类事件: I/O 事件、信号及定时事件。

有两种高效的事件处理模式: Reactor 和Proactor,同步I/O模型通常用于实现Reactor模式,异步I/O模型通常用于实现Proactor模式。

Reactor模式(主线程只监听)

要求主线程(I/O处理单元)只负责监听文件描述符上是否有事件发生,有的话就立即将该事件通知工作线程(逻辑单元),将socket可读可写事件放入请求队列,交给工作线程处理。除此之外,主线程不做任何其他实质性的工作。读写数据,接受新的连接,以及处理客户请求均在工作线程中完成。
使用同步I/O(以epoll_wait为例)实现的Reactor模式的工作流程是:

  1. 主线程往epoll内核事件表中注册socket上的读就绪事件。
  2. 主线程调用epoll_wait等待socket上有数据可读。
  3. 当socket上有数据可读时,epoll_wait通知主线程。主线程则将socket可读事件放入请求队列。(线程池)
  4. 睡眠在请求队列上的某个工作线程被唤醒,它从socket读取数据,并处理客户请求,然后往epoll内核事件表中注册该socket上的写就绪事件。
  5. 主线程调用epoll_wait等待socket可写。
  6. 当socket可写时,epoll_wait通知主线程。主线程将socket可写事件放入请求队列。
  7. 睡眠在请求队列上的某个工作线程被唤醒,它往socket上写入服务器处理客户请求的结果。

image-20221120192657906

Proactor模式(主线程监听+异步读写socket)

Proactor模式将所有I/O操作都交给主线程和内核来处理(进行读、写),工作线程仅仅负责业务逻辑。使用异步I/O模型(以aio_read和aio_write 为例)实现的Proactor 模式的工作流程是:

1.主线程调用aio_read 函数向内核注册socket上的读完成事件,并告诉内核 用户读缓冲区的位置,以及读操作完成时如何通知应用程序(这里以信号为例)。

2.主线程继续处理其他逻辑。

3.当socket上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号, 以通知应用程序数据已经可用。

4.应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求后,调用aio_ write函数向内核注册socket上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用程序。

5.主线程继续处理其他逻辑。

6.当用户缓冲区的数据被写入socket之后,内核将向应用程序发送一个信号, 以通知应用程序数据已经发送完毕。

7.应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭socket。

image-20221120194303146

⭐️使用同步IO的方式模拟Proactor

使用同步I/O方式模拟出Proactor模式。原理是:主线程执行数据读写操作,读写完成之后,主线程向工作线程通知这一”完成事件”。那么从工作线程的角度来看,它们就直接获得了数据读写的结果,接下来要做的只是对读写的结果进行逻辑处理。

使用同步I/O模型(以epoll_wait为例)模拟出的Proactor模式的工作流程如下:

  1. 主线程往epoll内核事件表中注册socket上的读就绪事件。
  2. 主线程调用epoll_wait 等待socket上有数据可读。
  3. 当socket上有数据可读时,epoll_wait通知主线程。主线程从socket循环读取数据,直到没有更多数据可读,然后将读取到的数据封装成一个请求对象并插入请求队列。
  4. 睡眠在请求队列上的某个工作线程被唤醒,它获得请求对象并处理客户请求,然后往epoll内核事件表中注册socket_上的写就绪事件。_
  5. 主线程调用epoll_wait 等待socket可写。
  6. 当socket可写时,epoll_wait 通知主线程。主线程往socket上写入服务器处理客户请求的结果。
image-20221120203431492

线程同步机制类封装及线程池实现

线程池

线程池是由服务器预先创建的一组子线程,线程池中的线程数量应该和CPU数量差不多。线程池中的所有子线程都运行着相同的代码。当有新的任务到来时,主线程将通过某种方式选择线程池中的某一个子线程来为之 服务。相比与动态的创建子线程,选择一个已经存在的子线程的代价显然要小得多。至于主线程选择哪个子线程来为新任务服务,则有多种方式:

●主线程使用某种算法来主动选择子线程。最简单、最常用的算法是随机算法和Round Robin (轮流选取)算法,但更优秀、更智能的算法将使任务在各个工作线程中更均匀地分配,从而减轻服务器的整体压力。

●主线程和所有子线程通过一个共享的工作队列来同步,子线程都睡眠在该工作队列上。当有新的任务到来时,主线程将任务添加到工作队列中。这将唤醒正在等待任务的子线程,不过只有一个子线程将获得新任务的”接管权”,它可以从工作队列中取出任务并执行之,而其他子线程将继续睡眠在工作队列上。

线程池的一般模型为:

image-20221120212110980

线程池中的线程数量最直接的限制因素是中央处理器(CPU)的处理器(processors/cores)的数量N:如果你的CPU是4-cores的,对于CPU密集型的任务(如视频剪辑等消耗CPU计算资源的任务)来说,那线程池中的线程数量最好也设置为4 (或者+1防止其他因素造成的线程阻塞) ;对于IO密集型的任务,一般要多于CPU的核数,因为线程间竞争的不是CPU的计算资源而是IO, IO的处理一般较慢, 多于cores数的线程将为CPU争取更多的任务,不至在线程处理IO的过程造成CPU空闲导致资源浪费。

●空间换时间,浪费服务器的硬件资源,换取运行效率。

●池是一组资源的集合,这组资源在服务器启动之初就被完全创建好并初始化,这称为静态资源。

●当服务器进入正式运行阶段,开始处理客户请求的时候,如果它需要相关的资源,可以直接从池中获取,无需动态分配。

●当服务器处理完一个客户连接后,可以把相关的资源放回池中,无需执行系统调用释放资源。

项目整体流程代码实现

有限状态机
STATE_MACHINE(Package _pack){
PackageType _type = _pack.GetType();
switch(_type){
case type_A:
process_package_A(_pack);
break;
case type_B:
process_package_B(_pack);
break;
}
}
  • 带状态转换的有限状态机
STATE_MACHINE( ){
State cur_State = type_A;
while(cur_State != type_C){
Package _pack = getNewPackage();
switch(cur_State){
case type_ A:
process_package_state_A(_pack);
cur_State = type_B;
break;
case type_B:
process_package_state_B(_pack);
cur_State = type_C;
break;
}
}
}

定时检测非活跃连接

/home/kjg/webserver/noactive

服务器压力测试

Webbench是Linux上一款知名的、优秀的web性能压力测试工具。它是由Lionbridge公司开发。

  • 测试处在相同硬件上,不同服务的性能以及不同硬件上同一个服务的运行状况。
  • 展示服务器的两项内容:每秒钟响应请求数和每秒钟传输数据量。

基本原理: Webbench首先fork出多个子进程,每个子进程都循环做web访问测试。子进程把访问的结果通过pipe告诉父进程,父进程做最终的统计结果。

测试示例.

cd webbench-1.5/
./webbench -c 1000 -t 30 http://192.168.110.129:10000/index.htm1

参数:
-c 表示客户端数
-t 表示时间