linux高并发服务器开发


牛客网C++项目学习笔记
原文链接:
https://www.nowcoder.com/study/live/504

第一章 Linux系统入门

1.0 环境配置

1.安装Linux系统(虚拟机安装、云服务器)
我的是centOS的系统
2.安装XSHELL、XFTP
https://www.netsarang.com/zh/free-for-home-school/
3.安装Visual Studio Code
https://code.visualstudio.com/

1.1 编程基础知识GCC\G++介绍

01 / 什么是GCC

GCC 原名为GNU C语言编译器(GNU C Compiler)
GCC(GNU Compiler Collection,GNU编译器套件)是由GNU 开发的编程语言译器。GNU 编译器套件包括C、C++、Objective-C、Java、Ada 和Go 语言前端,也包括了这些语言的库(如libstdc++,libgcj等)
GCC 不仅支持C 的许多“方言”,也可以区别不同的C 语言标准;可以使用命令行选项来控制编译器在翻译源代码时应该遵循哪个C 标准。例如,当使用命令行参数
-std=c99 启动GCC 时,编译器支持C99 标准。
安装命令sudo apt install gcc g++ (版本> 4.8.5)

(这里可以参考CSDN博主「禾烟雨」的原创文章安装新版的GCC原文链接:https://blog.csdn.net/mandiheyanyu/article/details/118995670)

centos7直接yum安装的那个gcc版本为4.8.5,对于大多数的需求来说都是低了。系统安装镜像里的那个版本也是4.8.5。现在最新的版本可能到9了,9.2 9.3的安装教程我在网上都搜到过,但是似乎9的版本都需要make和make install?编译时间得小半天吧,有时候还会有各种奇怪的报错。所以要求不是特别严格的话可以看看8.3.1版本,我不是很清楚它和这种费时巨大的在使用的时候会不会缺胳膊少腿的,但是我目前用着还行。

sudo yum install centos-release-scl
sudo yum install devtoolset-8-gcc*
scl enable devtoolset-8 bash
source /opt/rh/devtoolset-8/enable

后边执行mv命令的时候如果报错不用太过惊慌,这几句是用来替换掉直接安装的4.8.5版本的,但是没安装4.8.5软链接建立的命令也还是要执行的。

mv /usr/bin/gcc /usr/bin/gcc-4.8.5
ln -s /opt/rh/devtoolset-8/root/bin/gcc /usr/bin/gcc		# 软链接
mv /usr/bin/g++ /usr/bin/g++-4.8.5
ln -s /opt/rh/devtoolset-8/root/bin/g++ /usr/bin/g++
mv /usr/bin/c++ /usr/bin/c++-4.8.5
ln -s /opt/rh/devtoolset-8/root/bin/c++ /usr/bin/c++

可以查看一下版本看看了

gcc --version
g++ --version
c++ --version

如过在使用时出现如/usr/lib64/libstdc++.so.6: version `CXXABI_1.3.8’ not found这样的报错
拷入libstdc++.so.6.0.26在/usr/lib64下

mv libstdc++.so.6.0.26 /usr/lib64
cd /usr/lib64
rm -rf libstdc++.so.6
ln -s libstdc++.so.6.0.26 libstdc++.so.6

libstdc++.so.6.0.26放到百度网盘里了
链接:https://pan.baidu.com/s/1kba2_iWTxQrKAaQB60r2vg
提取码:29i1

image-20230130112823500

02 / 编程语言的发展

image-20230130113042053

03 / GCC工作流程

image-20230130113148667

04 / gcc 和g++ 的区别

gcc 和g++都是GNU(组织)的一个编译器。
误区一:gcc 只能编译c 代码,g++ 只能编译c++ 代码。两者都可以,请注意:

  • 后缀为.c 的,gcc 把它当作是C 程序,而g++ 当作是c++ 程序
  • 后缀为.cpp 的,两者都会认为是C++ 程序,C++ 的语法规则更加严谨一些
  • 编译阶段,g++ 会调用gcc,对于C++ 代码,两者是等价的,但是因为gcc命令不能自动和C++ 程序使用的库联接,所以通常用g++ 来完成链接,为了统
    一起见,干脆编译/链接统统用g++ 了,这就给人一种错觉,好像cpp 程序只能用g++ 似的

误区二:gcc 不会定义__cplusplus 宏,而g++ 会

  • 实际上,这个宏只是标志着编译器将会把代码按C 还是C++ 语法来解释
  • 如上所述,如果后缀为.c,并且采用gcc 编译器,则该宏就是未定义的,否则,就是已定义

误区三:编译只能用gcc,链接只能用g++

  • 严格来说,这句话不算错误,但是它混淆了概念,应该这样说:编译可以用gcc/g++,而链接可以用g++ 或者gcc -lstdc++。
  • gcc 命令不能自动和C++程序使用的库联接,所以通常使用g++ 来完成联接。但在编译阶段,g++ 会自动调用gcc,二者等价
#ifndef  __HEAD_H__ 
#define  __HEAD_H__
...  // 头文件内容
#endif

首先,使用该条件编译的目的是:防止该头文件重复引用。

比如,即使在主函数中写了两行#include “head.h”,该文件也只会被包含一次,如果头文件head.h中没有上面的条件编译,则会包含了2次,有可能会报错(是否报错要看具体的编译器,有些编译器不会报错)。

_HEAD_H_ 仅仅是一个标识,只要符合C语言标识命名规则,可以写任意命名,为了易读性,一般都会与头文件名一致。

其中的前两个下划线与后两个下划线__没有任何语法上的意义,有些资料说是系统自带的头文件会前后各加两个下划线__,用户自己编写的头文件可以不用双下划线__,以表示与系统头文件的区别。

最后HEAD_H中的下划线_表示头文件“HEAD.H”中的点,在C的语法中,“.”不能作为标识中的字符,所以用“_”表示

image-20230130154746091

image-20230130154801074

1.2 静态库和动态库

01 / 什么是库

  • 库文件是计算机上的一类文件可以简单的把库文件看成一种代码仓库,它提供给使用者一些可以直接拿来用的变量、函数或类
  • 库是特殊的一种程序,编写库的程序和编写一般的程序区别不大,只是库不能单独运行
  • 库文件有两种,静态库和动态库(共享库),区别是:静态库在程序的链接阶段被复制到了程序中动态库在链接阶段没有被复制到程序中,而是程序在运行时由系统动态加载到内存中供程序调用
  • 库的好处:1.代码保密 2.方便部署和分发

02 / 静态库与动态库的制作及使用

2.1 静态库的制作

image-20230130155408675

1. 只是汇编不链接gcc -c add.c div.c mult.c sub.c 就可以生成相应的.o文件
gcc -c *.c也行

2. ar rcs libcalc.a add.o div.o mult.o sub.o 就可以生成静态库文件 libcalc.a
ar rcs libcalc.a *.o也行
image-20230130155623329
// head.h
#ifndef _HEAD_H
#define _HEAD_H
int add(int a, int b);// 加法
int subtract(int a, int b);// 减法
int multiply(int a, int b);// 乘法
double divide(int a, int b);// 除法
#endif

//	add.c
#include <stdio.h>
#include "head.h"
int add(int a, int b){
    return a+b;
}
//	div.c
#include <stdio.h>
#include "head.h"
double divide(int a, int b){
    return (double)a/b;
}
// mult.c
#include <stdio.h>
#include "head.h"
int multiply(int a, int b){
    return a*b;
}
// sub.c
#include <stdio.h>
#include "head.h"
int subtract(int a, int b){
    return a-b;
}
2.2 静态库使用
gcc -o app main.c -l calc -L ./  		# 用于上面的文件全在一个文件夹,	-l calc -L ./  表示在当前文件夹加载库calc

image-20230130160957151

// main.c   
#include <stdio.h>
#include "head.h"

int main(){
    int a = 20;
    int b = 12;
    printf("a = %d, b = %d\n", a, b);
    printf("a + b = %d\n", add(a, b));
    printf("a - b = %d\n", subtract(a, b));
    printf("a * b = %d\n", multiply(a, b));
    printf("a / b = %f\n", divide(a, b));
    return 0;
}
gcc -c add.c sub.c mult.c div.c -I ../include/		# 指定include包含文件的搜索目录 (就是告诉程序去这个目录里面找头文件)
gcc main.c -o app -I ./include/ -l calc -L./lib		# 把main.c编译成app可执行文件  	-I头文件的目录     -l库的名称   -L库文件的目录
./app	# 执行文件
2.3 动态库制作过程及使用

image-20230131110647246

gcc -c -fpic add.c div.c mult.c sub.c		# 生成和位置无关的相应的.o文件
gcc -shared add.o sub.o mult.o div.o -o libcalc.so		# 生成libcalc.so文件(可执行)
image-20230131111139326
image-20230131111335000
gcc main.c -o main -I include/ -L lib/ -l calc	# 暂时执行不了,往下看

工作原理

  • 静态库:GCC 进行链接时,会把静态库中代码打包到可执行程序中
  • 动态库:GCC 进行链接时,动态库的代码不会被打包到可执行程序中
  • 程序启动之后,动态库会被动态加载到内存中,通过ldd (list dynamic dependencies)命令检查动态库依赖关系
  • 如何定位共享库文件呢?
    当系统加载可执行代码时候,能够知道其所依赖的库的名字,但是还需要知道绝对路径。此时就需要系统的动态载入器来获取该绝对路径。对于elf格式的可执行程序,是由ld-linux.so来完成的,它先后搜索elf文件的DT_RPATH段——> 环境变量LD_LIBRARY_PATH ——> /etc/ld.so.cache文件列表——> /lib/,/usr/lib目录找到库文件后将其载入内存

执行方法1:export命令加入到动态库环境变量

动态库文件目录用export命令加入到动态库环境变量(在终端里面配置的)

export LD_LIBRARY_PATH=$LD_LIBRARY_PAT:/home/nowcoder/Linux/lessono6/library/lib		# export命令把当前目录加入到动态库环境变量
echo $LD_LIBRARY_PAT		# 查看当前动态库环境变量
ldd main		# 检查动态库依赖关系

image-20230131112440797

执行方法2: 永久配置

1 环境变量 $LD_LIBRARY_PAT

用户级别:

.进去到home目录下 找到隐藏文件.bashrc

img,shift+g跳到最后一行,o插入

export LD_LIBRARY_PATH=$LD_LIBRARY_PAT:/home/nowcoder/Linux/lessono6/library/lib	# export命令把当前目录加入到动态库环境变量
:wq 	# 保存并退出

系统级别:

img 在这个文件最后里面插入下面语句

export LD_LIBRARY_PATH=$LD_LIBRARY_PAT:/home/nowcoder/Linux/lessono6/library/lib	# export命令把当前目录加入到动态库环境变量
:wq 	# 保存并退出

img刷新一下该文件(之后建议重启终端)

image-20230131113419702

2 /etc/ld.so .cache文件列表

ll /etc/ld.so.cache			# 查看
sudo vim /etc/ld.so.conf	# 通过修改这个文件间接的修改上面的文件。把我们的路径加进去

image-20230131114129246

sudo ldconfig 	# 然后更新

03 / 静态库和动态库的对比

3.1 程序编译成可执行程序的过程

image-20230131114732554

静态库、动态库区别来自链接阶段如何处理,链接成可执行程序。分别称为静态链接方式和动态链接方式。

1预处理:1. 头文件展开 2. 删除注释 3.宏的替换

2编译:编译成汇编代码

3汇编:通过汇编器完成汇编,形成目标代码,此时就是后缀为.o的文件。 使用 gcc -c能走123步骤

4链接:对于.a静态库文件打包复制过去,对于.so动态库文件打包动态库名称,有前面的.o文件后 使用 gcc -o能走4步骤,当然使用 gcc -o能走1234步骤从源文件到可执行文件

3.2 静态库制作过程

image-20230131114928093

3.3 动态库制作过程

image-20230131110452661

3.4 静态库的优缺点

image-20230131120443108

  • 优点:
    ◆ 静态库被打包到应用程序中加载速度快
    ◆ 发布程序无需提供静态库,移植方便
  • 缺点:
    ◆ 消耗系统资源,浪费内存
    ◆ 更新、部署、发布麻烦
  • 库文件比较小的用它
3.5 动态库的优缺点

image-20230131120636361

  • 优点:
    ◆ 可以实现进程间资源共享(共享库)
    ◆ 更新、部署、发布简单
    ◆ 可以控制何时加载动态库
  • 缺点:
    ◆ 加载速度比静态库慢
    ◆ 发布程序时需要提供依赖的动态库
  • 库文件比较大的用它

1.3 Makefile

01 / 什么是Makefile

  • 一个工程中的源文件不计其数,其按类型、功能、模块分别放在若干个目录中,Makefile 文件定义了一系列的规则来指定哪些文件需要先编译,哪些文件需要后编译,哪些文件需要重新编译,甚至于进行更复杂的功能操作,因为Makefile 文件就像一个Shell 脚本一样,也可以执行操作系统的命令。
  • Makefile 带来的好处就是“自动化编译” ,一旦写好,只需要一个make 命令,整个工程完全自动编译,极大的提高了软件开发的效率。make 是一个命令工具,是一个解释Makefile 文件中指令的命令工具,一般来说,大多数的IDE 都有这个命令,比如Delphi 的make,Visual C++ 的nmake,Linux 下GNU 的make。

02 / Makefile 文件命名和规则

  • 文件命名
    makefile 或者Makefile
  • Makefile 规则
    • 一个Makefile 文件中可以有一个或者多个规则
      目标…: 依赖…
      命令(Shell 命令)
    • 目标:最终要生成的文件(伪目标除外)
    • 依赖:生成目标所需要的文件或是目标
    • 命令:通过执行命令对依赖操作生成目标(命令前必须Tab 缩进)
    • Makefile 中的其它规则一般都是为第一条规则服务的。

03 / 工作原理

  • 命令在执行之前,需要先检查规则中的依赖是否存在
     如果存在,执行命令
     如果不存在,向下检查其它的规则,检查有没有一个规则是用来生成这个依赖的,如果找到了,则执行该规则中的命令
  • 检测更新,在执行规则中的命令时,会比较目标和依赖文件的时间
     如果依赖的时间比目标的时间晚,需要重新生成目标
     如果依赖的时间比目标的时间早,目标不需要更新,对应规则中的命令不需要被执行

04 / 变量

自定义变量
变量名=变量值 var=hello
预定义变量

AR : 归档维护程序的名称,默认值为ar
CC : C 编译器的名称,默认值为cc
CXX : C++ 编译器的名称,默认值为g++
$@ : 目标的完整名称				# 只能在命令里面使用
$< : 第一个依赖文件的名称			  # 只能在命令里面使用
$^ : 所有的依赖文件				# 只能在命令里面使用

# 获取变量的值
$(变量名)		如:$(var)
app:main.c a.c b.c				# app是要生成的目标,	main.c a.c b.c是需要的依赖
	gcc -c main.c a.c b.c		# 执行的命令
# 自动变量只能在规则的命令中使用
app:main.c a.c b.c
	$(CC) -c $^ -o $@		# $(CC)表示gcc , $^表示所有的依赖(main.c a.c b.c), $@表示目标的名称app
# 简单版低效
app:sub.c add.c mult.c div.c main.c
	gcc sub.c add.c mult.c div.c main.c -o app
	
# 复杂版高效:  修改的时候只会编译修改过的文件(如果后面的目标 不是第一条目标依赖的文件则默认不会执行,需要手动设置)
app:sub.o add.o mult.o div.o main.o
	gcc sub.o add.o mult.o div.o main.o -o app
sub.o:sub.c
	gcc -c sub.c -o sub.o
add.o:add.c
	gcc -c add.c -o add.o
mult.o:mult.c
	gcc -c mult.c -o mult.o
div.o:div.c
	gcc -c div.c -o div.o
main.o:main.c
	gcc -c main.c -o main.o

#定义变量
src=sub.o add.o mult.o div.o main.o
target=app
$(target):$(src)
	$(CC) $(src) -o $(target)
sub.o:sub.c
	gcc -c sub.c -o sub.o
add.o:add.c
	gcc -c add.c -o add.o
mult.o:mult.c
	gcc -c mult.c -o mult.o
div.o:div.c
	gcc -c div.c -o div.o
main.o:main.c
	gcc -c main.c -o main.o

#定义变量带匹配模式
src=sub.o add.o mult.o div.o main.o
target=app
$(target):$(src)
	$(CC) $(src) -o $(target)
%.o:%.c		# sub.o add.o mult.o div.o main.o从这些文件里面去挨个匹配  如第一个sub.o:sub.c  
# %.o:%.c	- %: 通配符,匹配一个字符串		- 两个%匹配的是同一个字符串
	$(CC) -c $< -o $@

05 / 函数

$(wildcard PATTERN…)
 功能:获取指定目录下指定类型的文件列表
 参数:PATTERN 指的是某个或多个目录下的对应的某种类型的文件,如果有多个目录,一般使用空格间隔
 返回:得到的若干个文件的文件列表,文件名之间使用空格间隔
 示例:

$(wildcard *.c ./sub/*.c)
返回值格式: a.c b.c c.c d.c e.c f.c

$(patsubst <pattern>,<replacement>,<text>)
 功能:查找<text>中的单词(单词以“空格”、“Tab”或“回车”“换行”分隔)是否符合模式<pattern>,如果匹配的话,则以<replacement>替换。
 <pattern>可以包括通配符%,表示任意长度的字串。如果<replacement>中也包含%,那么,<replacement>中的这个%将是<pattern>中的那个%所代表的字串。(可以用\来转义,以\%来表示真实含义的%字符)
 返回:函数返回被替换过后的字符串
 示例:

$(patsubst %.c, %.o, x.c bar.c)		# 在此处其实就是把x.c bar.c替换成x.o bar.o
返回值格式: x.o bar.o
#定义变量
# 返回文件列表用空格分开得到:add.c sub.c main.c mult.c div.c
src=$(wildcard ./*.c)		# ./当前目录  *.c所有的.c文件
# 查找src变量的文本,其中.c替换为.o返回得到:add.o sub.o main.o mult.o div.o
objs=$(patsubst %.c, %.o, $(src))
target=app
$(target):$(objs)
	$(CC) $(objs) -o $(target)

%.o:%.c				# 从$(objs)的这些文件add.o sub.o main.o mult.o div.o去匹配,	检测这些依赖是不是最新的
	$(CC) -c $< -o $@
#.PHONY:clean表示当前的clean为伪目标不会生成clean文件
.PHONY:clean
clean:
	rm $(objs) -fv

执行make clean清楚中间的.o文件

image-20230131163139822

1.4 GDB调试

01 / 什么是GDB

  • GDB 是由GNU 软件系统社区提供的调试工具,同GCC 配套组成了一套完整的开发环境,GDB 是Linux 和许多类Unix 系统中的标准开发环境。
  • 一般来说,GDB 主要帮助你完成下面四个方面的功能:
  1. 启动程序,可以按照自定义的要求随心所欲的运行程序
  2. 可让被调试的程序在所指定的调置的断点处停住(断点可以是条件表达式)
  3. 当程序被停住时,可以检查此时程序中所发生的事
  4. 可以改变程序,将一个BUG 产生的影响修正从而测试其他BUG

02 / 准备工作

  • 通常,在为调试而编译时,我们会()关掉编译器的优化选项(-O), 并打开调试选项(-g)。另外,-Wall在尽量不影响程序行为的情况下选项打开所有warning,也可以发现许多问题,避免一些不必要的BUG。

    • ```bash
      gcc -g -Wall program.c -o program
      
          - -g 选项的作用是**在可执行文件中加入源代码的信息**,比如可执行文件中第几条机器指令对应源代码的第几行,但并不是把整个源文件嵌入到可执行文件中,所以在调试时必须保证gdb 能找到源文件。
      
      #### 03 / GDB 命令
      
      **启动、退出、查看代码**
      
      ![image-20230201121021177](https://geyangwen-images.oss-cn-hangzhou.aliyuncs.com/2023/linux/mediaimage-20230201121021177.png)
      
      ```bash
      g++ bubble.cpp main .cpp select.cpp -o main -g		# 有些c++的库找不到时可以用g++编译

断点操作

image-20230201121327267

调试命令

image-20230201121340514

1.5 文件IO

01 / 标准C 库IO 函数

image-20230201121745876

02 / 标准C 库IO 和Linux 系统IO 的关系

  • 标准C库的函数可以跨平台(可以调用不同系统的API) 标准C库的IO函数效率高一些
  • Linux系统的IO函数比较适用于网络的,IO写了就发,读就读,不会缓存更高效快速

image-20230201122122082

image-20230201123206210

03 / 虚拟地址空间

image-20230201123351264

可执行程序运行期间会对应一个虚拟地址空间(我们想象的,其实是通过MMU内存管理单元映射到真实的物理内存,同时他也不会真正的占用4G的物理内存),运行结束了就不存在这个虚拟空间,运行起来就叫进程。

32位的可以虚拟出2^32^=4G的空间,64位的可以虚拟出好像是2(反正很大哟)

04 / 文件描述符

image-20230202114054519

程序和进程的区别

  • 程序只占用磁盘空间,
  • 进程还需要占用内存空间(跑起来的程序)

05 / Linux 系统IO 函数

  • Linux系统函数在man 2 open/write…查找
  • 标准C库的函数man 3 fopen/fwrite…查找
int open(const char *pathname, int flags);				// 打开一个文件,正常返回文件描述符
int open(const char *pathname, int flags, mode_t mode);	// 可以创建一个文件,正常返回文件描述符
int close(int fd);
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
off_t lseek(int fd, off_t offset, int whence);		//1.移动文件指针到文件头,2.获取当前文件指针的位置, 3.获取文件长度,4.拓展文件的长度
int stat(const char *pathname, struct stat *statbuf);	//获取一个具体文件相关的一些信息,也可以在linux的bash中输入stat 文件全称
int lstat(const char *pathname, struct stat *statbuf);	//获取一个软连接相关的一些信息

struct stat {
    dev_t st_dev; // 文件的设备编号
    ino_t st_ino; // 节点
    mode_t st_mode; // 文件的类型和存取的权限		//下面有专门解释的
    nlink_t st_nlink; // 连到该文件的硬连接数目
    uid_t st_uid; // 用户ID
    gid_t st_gid; // 组ID
    dev_t st_rdev; // 设备文件的设备编号
    off_t st_size; // 文件字节数(文件大小)		//比较常用
    blksize_t st_blksize; // 块大小
    blkcnt_t st_blocks; // 块数
    time_t st_atime; // 最后一次访问时间
    time_t st_mtime; // 最后一次修改时间
    time_t st_ctime; // 最后一次改变时间(指属性)
};
/*
    #include <sys/types.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    // 创建一个新的文件
    int open(const char *pathname, int flags, mode_t mode);
        参数:
            - pathname:要创建的文件的路径
            - flags:对文件的操作权限和其他的设置
                - 必选项:O_RDONLY,  O_WRONLY, O_RDWR  这三个之间是互斥的
                - 可选项:O_CREAT 文件不存在,创建新文件
            - mode:八进制的数,表示创建出的新的文件的操作权限,比如:0777
            最终的权限是:mode & ~umask
            0777   ->   111111111
        &   0775   ->   111111101
        ----------------------------
                        111111101
        按位与:0和任何数都为0
        umask的作用就是抹去某些权限。

        flags参数是一个int类型的数据,占4个字节,32位。
        flags 32个位,每一位就是一个标志位。

*/
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int main() {
    // 创建一个新的文件
    int fd = open("create.txt", O_RDWR | O_CREAT, 0777);
    if(fd == -1) perror("open");

    // 关闭
    close(fd);
    return 0;
}

/*
    // 打开一个已经存在的文件
    int open(const char *pathname, int flags);
        参数:
            - pathname:要打开的文件路径
            - flags:对文件的操作权限设置还有其他的设置
              O_RDONLY(只读),  O_WRONLY(只写),  O_RDWR(可读可写)  这三个设置是互斥的
        返回值:返回一个新的文件描述符(定位文件),如果调用失败,返回-1

    errno:属于Linux系统函数库,库里面的一个全局变量,记录的是最近的错误号。

    #include <stdio.h>
    void perror(const char *s);作用:打印errno对应的错误描述
        s参数:用户描述,比如hello,最终输出的内容是  hello:xxx(实际的错误描述)
    
    // 创建一个新的文件
    int open(const char *pathname, int flags, mode_t mode);
*/
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>

int main() {
    // 打开一个文件
    int fd = open("a.txt", O_RDONLY);
    if(fd == -1)  perror("open");
    // 读操作

    // 关闭
    close(fd);//关闭文件释放文件描述符
    return 0;
}
/*  
    #include <unistd.h>
    ssize_t read(int fd, void *buf, size_t count);
        参数:  
            - fd:文件描述符,open得到的,通过这个文件描述符操作某个文件
            - buf:需要读取数据存放的地方,数组的地址(传出参数)
            - count:指定的数组的大小
        返回值:
            - 成功:
                >0: 返回实际的读取到的字节数
                =0:文件已经读取完了
            - 失败:-1 ,并且设置errno

    #include <unistd.h>
    ssize_t write(int fd, const void *buf, size_t count);
        参数:
            - fd:文件描述符,open得到的,通过这个文件描述符操作某个文件
            - buf:要往磁盘写入的数据,一般是数组
            - count:要写的数据的实际的大小
        返回值:
            成功:实际写入的字节数
            失败:返回-1,并设置errno
*/
#include <unistd.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int main() {
    // 1.通过open打开english.txt文件
    int srcfd = open("english.txt", O_RDONLY);
    if(srcfd == -1) {
        perror("open");
        return -1;
    }

    // 2.创建一个新的文件(拷贝文件)
    int destfd = open("cpy.txt", O_WRONLY | O_CREAT, 0664);
    if(destfd == -1) {
        perror("open");
        return -1;
    }

    // 3.频繁的读写操作
    char buf[1024] = {0};
    int len = 0;
    while((len = read(srcfd, buf, sizeof(buf))) > 0) {
        write(destfd, buf, len);
    }
    // 4.关闭文件
    close(destfd);
    close(srcfd);
    return 0;
}
/*  
    标准C库的函数
    #include <stdio.h>
    int fseek(FILE *stream, long offset, int whence);   FILE * 是文件指针

    Linux系统函数
    #include <sys/types.h>
    #include <unistd.h>
    off_t lseek(int fd, off_t offset, int whence);
        参数:
            - fd:文件描述符,通过open得到的,通过这个fd操作某个文件
            - offset:偏移量
            - whence:
                SEEK_SET
                    设置文件指针的偏移量
                SEEK_CUR
                    设置偏移量:当前位置 + 第二个参数offset的值
                SEEK_END
                    设置偏移量:文件大小 + 第二个参数offset的值
        返回值:返回文件指针的位置


    作用:
        1.移动文件指针到文件头
        lseek(fd, 0, SEEK_SET);

        2.获取当前文件指针的位置
        lseek(fd, 0, SEEK_CUR);

        3.获取文件长度
        lseek(fd, 0, SEEK_END);

        4.拓展文件的长度,当前文件10b, 110b, 增加了100个字节
        lseek(fd, 100, SEEK_END)
        注意:需要写一次数据

*/

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int main() {

    int fd = open("hello.txt", O_RDWR);
    if(fd == -1) { perror("open");  return -1;    }

    // 扩展文件的长度
    int ret = lseek(fd, 100, SEEK_END);
    if(ret == -1) { perror("lseek");   return -1;}

    // 写入一个空数据
    write(fd, " ", 1);

    // 关闭文件
    close(fd);
    return 0;
}
/*
    #include <sys/types.h>
    #include <sys/stat.h>
    #include <unistd.h>

    int stat(const char *pathname, struct stat *statbuf);
        作用:获取一个文件相关的一些信息
        参数:
            - pathname:操作的文件的路径
            - statbuf:结构体变量,传出参数,用于保存获取到的文件的信息
        返回值:
            成功:返回0
            失败:返回-1 设置errno

    int lstat(const char *pathname, struct stat *statbuf);	//作用:获取一个软连接文件(不包含链接的文件)相关的一些信息
        参数:
            - pathname:操作的文件的路径
            - statbuf:结构体变量,传出参数,用于保存获取到的文件的信息
        返回值:
            成功:返回0
            失败:返回-1 设置errno
*/

#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <stdio.h>

int main() {
    struct stat statbuf;
    int ret = stat("a.txt", &statbuf);
    if(ret == -1) { perror("stat");  return -1; }
    printf("size: %ld\n", statbuf.st_size);
    return 0;
}

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <pwd.h>
#include <grp.h>
#include <time.h>
#include <string.h>

// 模拟实现 ls -l 指令
// -rw-rw-r-- 1 nowcoder nowcoder 12 12月  3 15:48 a.txt
int main(int argc, char *argv[]){

    // 判断输入的参数是否正确
    if (argc < 2){
        printf("%s filename\n",argv[0]);
        return -1;
    }

    // 通过stat函数获取用户传入的文件的信息
    struct stat st;
    int ret = stat(argv[1], &st);
    if(ret==-1){
        perror("stat");
        return -1;
    }

    // 获取文件类型和文件权限
    char perms[11]={0};    // 用于保存文件类型和文件权限的字符串
    switch(st.st_mode & S_IFMT){
        case S_IFSOCK:
            perms[0]='s';
            break;
        case S_IFLNK:
            perms[0]='l';
            break;
        case S_IFREG:
            perms[0]='-';
            break;
        case S_IFBLK:
            perms[0]='b';
            break;
        case S_IFDIR:
            perms[0]='d';
            break;
        case S_IFCHR:
            perms[0]='c';
            break;
        case S_IFIFO:
            perms[0]='p';
            break;
        default:
            perms[0]='?';
            break;
    }
    // 判断文件的访问权限

    // 文件所有者
    perms[1]=(st.st_mode & S_IRUSR)?'r':'-';
    perms[2]=(st.st_mode & S_IWUSR)?'w':'-';
    perms[3]=(st.st_mode & S_IXUSR)?'x':'-';    
    // 文件所在组
    perms[4]=(st.st_mode & S_IRGRP)?'r':'-';
    perms[5]=(st.st_mode & S_IWGRP)?'w':'-';
    perms[6]=(st.st_mode & S_IXGRP)?'x':'-';
    // 其他人
    perms[7]=(st.st_mode & S_IROTH)?'r':'-';
    perms[8]=(st.st_mode & S_IWOTH)?'w':'-';
    perms[9]=(st.st_mode & S_IXOTH)?'x':'-';
    // 硬连接数
    int linkNum = st.st_nlink;

    // 文件所有者
    char * fileUser = getpwuid(st.st_uid)->pw_name;

    // 文件所在组
    char * fileGrp = getgrgid(st.st_gid)->gr_name;

    // 文件大小
    long int fileSize = st.st_size;

    // 获取修改的时间
    char * time = ctime(&st.st_mtime);

    char mtime[512] = {0};
    strncpy(mtime, time, strlen(time) - 1);

    char buf[1024];
    sprintf(buf, "%s %d %s %s %ld %s %s", perms, linkNum, fileUser, fileGrp, fileSize, mtime, argv[1]);
    printf("%s\n", buf);

    return 0;
}

06 / st_mode 变量

image-20230202122045988

07 / 文件属性操作函数

int access(const char *pathname, int mode);		//判断某个文件是否有某个权限,或者判断文件是否存在*
int chmod(const char *filename, int mode);		//修改文件的权限
int chown(const char *path, uid_t owner, gid_t group);	 //修改文件的所有者或者所在组	//这两个id在/etc/passwd,或者/etc/group,或者id 用户名
int truncate(const char *path, off_t length);			//缩减或者扩展文件的尺寸至指定的大小*
/*
    #include <unistd.h>
    int access(const char *pathname, int mode);
        作用:判断某个文件是否有某个权限,或者判断文件是否存在
        参数:
            - pathname: 判断的文件路径
            - mode:
                R_OK: 判断是否有读权限
                W_OK: 判断是否有写权限
                X_OK: 判断是否有执行权限
                F_OK: 判断文件是否存在
        返回值:成功返回0, 失败返回-1
*/

#include <unistd.h>
#include <stdio.h>
int main() {
    int ret = access("a.txt", F_OK);
    if(ret == -1) { perror("access");}
    printf("文件存在!!!\n");
    return 0;
}
/*
    #include <sys/stat.h>
    int chmod(const char *pathname, mode_t mode);
        修改文件的权限
        参数:
            - pathname: 需要修改的文件的路径
            - mode:需要修改的权限值,八进制的数
        返回值:成功返回0,失败返回-1
*/
#include <sys/stat.h>
#include <stdio.h>
int main() {
    int ret = chmod("a.txt", 0777);
    if(ret == -1) { perror("chmod");  return -1;}
    return 0;
}
/*
    #include <unistd.h>
    #include <sys/types.h>
    int truncate(const char *path, off_t length);
        作用:缩减或者扩展文件的尺寸至指定的大小
        参数:
            - path: 需要修改的文件的路径
            - length: 需要最终文件变成的大小(单位:字节)
        返回值:
            成功返回0, 失败返回-1
*/

#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>
int main() {
    int ret = truncate("b.txt", 5);
    if(ret == -1) { perror("truncate"); return -1;}
    return 0;
}

08 / 目录操作函数

int rename(const char *oldpath, const char *newpath); // 重命名一个目录
int chdir(const char *path);						// 修改进程的工作目录
char *getcwd(char *buf, size_t size);				 // 获取当前工作目录
int mkdir(const char *pathname, mode_t mode);		  // 创建一个目录
int rmdir(const char *pathname);					 // 删除空目录
/*
    #include <stdio.h>
    int rename(const char *oldpath, const char *newpath);
*/
#include <stdio.h>

int main() {
    int ret = rename("aaa", "bbb");
    if(ret == -1) { perror("rename"); return -1;}
    return 0;
}

/*
    #include <unistd.h>
    int chdir(const char *path);
        作用:修改进程的工作目录
            比如在/home/nowcoder 启动了一个可执行程序a.out, 进程的工作目录 /home/nowcoder
        参数:
            path : 需要修改的工作目录

    #include <unistd.h>
    char *getcwd(char *buf, size_t size);
        作用:获取当前工作目录
        参数:
            - buf : 存储的路径,指向的是一个数组(传出参数)
            - size: 数组的大小
        返回值:
            返回的指向的一块内存,这个数据就是第一个参数
*/
#include <unistd.h>
#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>

int main() {
    // 获取当前的工作目录
    char buf[128];
    getcwd(buf, sizeof(buf));
    printf("当前的工作目录是:%s\n", buf);

    // 修改工作目录
    int ret = chdir("/home/nowcoder/Linux/lesson13");
    if(ret == -1) {
        perror("chdir");
        return -1;
    } 

    // 创建一个新的文件
    int fd = open("chdir.txt", O_CREAT | O_RDWR, 0664);
    if(fd == -1) {
        perror("open");
        return -1;
    }
    close(fd);

    // 获取当前的工作目录
    char buf1[128];
    getcwd(buf1, sizeof(buf1));
    printf("当前的工作目录是:%s\n", buf1);
    return 0;
}
/*
    #include <sys/stat.h>
    #include <sys/types.h>
    int mkdir(const char *pathname, mode_t mode);
        作用:创建一个目录
        参数:
            pathname: 创建的目录的路径
            mode: 权限,八进制的数
        返回值:
            成功返回0, 失败返回-1
*/

#include <sys/stat.h>
#include <sys/types.h>
#include <stdio.h>

int main() {
    int ret = mkdir("aaa", 0777);
    if(ret == -1) { perror("mkdir"); return -1;}
    return 0;
}

09 / 目录遍历函数

DIR *opendir(const char *name);		// 打开一个目录
struct dirent *readdir(DIR *dirp);	// 读取目录中的数据
int closedir(DIR *dirp);		   // 关闭目录
/*
    // 打开一个目录
    #include <sys/types.h>
    #include <dirent.h>
    DIR *opendir(const char *name);
        参数:
            - name: 需要打开的目录的名称
        返回值:
            DIR * 类型,理解为目录流
            错误返回NULL


    // 读取目录中的数据
    #include <dirent.h>
    struct dirent *readdir(DIR *dirp);
        - 参数:dirp是opendir返回的结果
        - 返回值:
            struct dirent,代表读取到的文件的信息
            读取到了末尾或者失败了,返回NULL

    // 关闭目录
    #include <sys/types.h>
    #include <dirent.h>
    int closedir(DIR *dirp);
*/
#include <sys/types.h>
#include <dirent.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
// 用于获取目录下所有普通文件的个数
int getFileNum(const char * path);

// 读取某个目录下所有的普通文件的个数
int main(int argc, char * argv[]) {
    if(argc < 2) {
        printf("%s path\n", argv[0]);
        return -1;
    }

    int num = getFileNum(argv[1]);
    printf("普通文件的个数为:%d\n", num);
    return 0;
}

// 用于获取目录下所有普通文件的个数
int getFileNum(const char * path) {
    // 1.打开目录
    DIR * dir = opendir(path);
    if(dir == NULL) {
        perror("opendir");
        exit(0);
    }

    struct dirent *ptr;
    // 记录普通文件的个数
    int total = 0;

    while((ptr = readdir(dir)) != NULL) {
        // 获取名称
        char * dname = ptr->d_name;
        // 忽略掉. 和..
        if(strcmp(dname, ".") == 0 || strcmp(dname, "..") == 0) {
            continue;
        }

        // 判断是否是普通文件还是目录
        if(ptr->d_type == DT_DIR) {
            // 目录,需要继续读取这个目录
            char newpath[256];
            sprintf(newpath, "%s/%s", path, dname);
            total += getFileNum(newpath);
        }

        if(ptr->d_type == DT_REG) {
            // 普通文件
            total++;
        }
    }
    // 关闭目录
    closedir(dir);
    return total;
}

10 / dup、dup2 函数

int dup(int oldfd);  				//复制文件描述符
int dup2(int oldfd, int newfd);		//重定向文件描述符
/*
    #include <unistd.h>
    int dup(int oldfd);
        作用:复制一个新的文件描述符
        fd=3, int fd1 = dup(fd),
        fd指向的是a.txt, fd1也是指向a.txt
        从空闲的文件描述符表中找一个最小的,作为新的拷贝的文件描述符
*/
#include <unistd.h>
#include <stdio.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
int main() {
    int fd = open("a.txt", O_RDWR | O_CREAT, 0664);
    int fd1 = dup(fd);
    if(fd1 == -1) {
        perror("dup");
        return -1;
    }
    printf("fd : %d , fd1 : %d\n", fd, fd1);
    close(fd);

    char * str = "hello,world";
    int ret = write(fd1, str, strlen(str));
    if(ret == -1) {
        perror("write");
        return -1;
    }
    close(fd1);
    return 0;
}
/*
    #include <unistd.h>
    int dup2(int oldfd, int newfd);
        作用:重定向文件描述符
        oldfd 指向 a.txt,   newfd 指向 b.txt
        调用函数成功后:newfd 和 b.txt 不在关联, newfd 指向了 a.txt
        oldfd 必须是一个有效的文件描述符
        oldfd和newfd值相同,相当于什么都没有做
*/
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>

int main() {
    int fd = open("1.txt", O_RDWR | O_CREAT, 0664);
    if(fd == -1) {
        perror("open");
        return -1;
    }
    int fd1 = open("2.txt", O_RDWR | O_CREAT, 0664);
    if(fd1 == -1) {
        perror("open");
        return -1;
    }
    printf("fd : %d, fd1 : %d\n", fd, fd1);

    int fd2 = dup2(fd, fd1);		// fd1 和 2.txt 不在关联
    if(fd2 == -1) {
        perror("dup2");
        return -1;
    }

    // 通过fd1去写数据,实际操作的是1.txt,而不是2.txt
    char * str = "hello, dup2";
    int len = write(fd1, str, strlen(str));
    if(len == -1) {
        perror("write");
        return -1;
    }

    printf("fd : %d, fd1 : %d, fd2 : %d\n", fd, fd1, fd2);		//fd1 == fd2
    close(fd);
    close(fd1);
    return 0;
}

11 / fcntl 函数

int fcntl(int fd, int cmd, ... /* arg */ );
复制文件描述符
设置/获取文件的状态标志
/*
    #include <unistd.h>
    #include <fcntl.h>
    int fcntl(int fd, int cmd, ...);
    参数:
        fd : 表示需要操作的文件描述符
        cmd: 表示对文件描述符进行如何操作
            - F_DUPFD : 复制文件描述符,复制的是第一个参数fd,得到一个新的文件描述符(返回值)
                int ret = fcntl(fd, F_DUPFD);

            - F_GETFL : 获取指定的文件描述符文件状态flag
              获取的flag和我们通过open函数传递的flag是一个东西。

            - F_SETFL : 设置文件描述符文件状态flag *
              必选项:O_RDONLY, O_WRONLY, O_RDWR 不可以被修改
              可选性:O_APPEND, O)NONBLOCK
                O_APPEND 表示追加数据
                NONBLOK 设置成非阻塞
                
        阻塞和非阻塞:描述的是函数调用的行为。
*/

#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>

int main() {

    // 1.复制文件描述符
    // int fd = open("1.txt", O_RDONLY);
    // int ret = fcntl(fd, F_DUPFD);

    // 2.修改或者获取文件状态flag
    int fd = open("1.txt", O_RDWR);
    if(fd == -1) {
        perror("open");
        return -1;
    }

    // 获取文件描述符状态flag
    int flag = fcntl(fd, F_GETFL);
    if(flag == -1) {
        perror("fcntl");
        return -1;
    }
    flag |= O_APPEND;   // flag = flag | O_APPEND

    // 修改文件描述符状态的flag,给flag加入O_APPEND这个标记
    int ret = fcntl(fd, F_SETFL, flag);
    if(ret == -1) {
        perror("fcntl");
        return -1;
    }

    char * str = "nihao";
    write(fd, str, strlen(str));
    close(fd);
    return 0;
}

第二章 Linux多进程开发

2.1 进程概述

01 / 程序和进程

程序是包含一系列信息的文件(不占CPU资源),这些信息描述了如何在运行时创建一个进程:

  • 二进制格式标识:每个程序文件都包含用于描述可执行文件格式的元信息。内核利用此信息来解释文件中的其他信息。(ELF可执行连接格式)

  • 机器语言指令:对程序算法进行编码。

  • 程序入口地址:标识程序开始执行时的起始指令位置。

  • 数据:程序文件包含的变量初始值和程序使用的字面量值(比如字符串)。

  • 符号表及重定位表:描述程序中函数和变量的位置及名称。这些表格有多重用途,其中包括调试和运行时的符号解析(动态链接)。

  • 共享库和动态链接信息:程序文件所包含的一些字段,列出了程序运行时需要使用的共享库,以及加载共享库的动态连接器的路径名。

  • 其他信息:程序文件还包含许多其他信息,用以描述如何创建进程。

  • 进程是正在运行的程序的实例(要占CPU资源)。是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。

  • 可以用一个程序来创建多个进程,进程是由内核定义的抽象实体并为该实体分配用以执行程序的各项系统资源。从内核的角度看,进程由用户内存空间和一系列内核数据结构组成(其实就是虚拟内存表),其中用户内存空间包含了程序代码及代码所使用的变量,而内核数据结构则用于维护进程状态信息。记录在内核数据结构中的信息包括许多与进程相关的标识号(IDs)、虚拟内存表、打开文件的描述符表、信号传递及处理的有关信息、进程资源使用及限制、当前工作目录和大量的其他信息。

02 / 单道、多道程序设计

  1. 单道程序,即在计算机内存中只允许一个的程序运行。
  2. 多道程序设计技术是在计算机内存中同时存放几道相互独立的程序,使它们在管理程序控制下,相互穿插运行,两个或两个以上程序在计算机系统中同处于开始到结束之间的状态, 这些程序共享计算机系统资源。引入多道程序设计技术的根本目的是为了提高CPU 的利用率。
  3. 对于一个单CPU 系统来说,程序同时处于运行状态只是一种宏观上的概念,他们虽然都已经开始运行,但就微观而言,任意时刻,CPU 上运行的程序只有一个
  4. 多道程序设计模型中,多个进程轮流使用CPU。而当下常见CPU 为纳秒级,1秒可以执行大约10 亿条指令。由于人眼的反应速度是毫秒级,所以看似同时在运行。

03 / 时间片*

时间片(timeslice)又称为“量子(quantum)”或“处理器片(processor slice)”是操作系统分配给每个正在运行的进程微观上的一段CPU 时间。事实上,虽然一台计算机通常可能有多个CPU,但是同一个CPU 永远不可能真正地同时运行多个任务。在只考虑一个CPU 的情况下,这些进程“看起来像”同时运行的,实则是轮番穿插地运行,由于时间片通常很短(在Linux 上为5ms-800ms),用户不会感觉到。

时间片由操作系统内核的调度程序分配给每个进程首先,内核会给每个进程分配相等的初始时间片,然后每个进程轮番地执行相应的时间,当所有进程都处于时间片耗尽的状态时,内核会重新为每个进程计算并分配时间片,如此往复。

这里还涉及到进程调度策略。

04 / 并行和并发

  • 并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。
  • 并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。

image-20230204115934024

  • 并发是两个队列交替使用一台咖啡机。
  • 并行是两个队列同时使用两台咖啡机。

image-20230204120047387

05 / 进程控制块(PCB)

为了管理进程,内核必须对每个进程所做的事情进行清楚的描述。内核为每个进程分配一个PCB(Processing Control Block)进程控制块,维护进程相关的信息,
Linux 内核的进程控制块是task_struct 结构体。
在/usr/src/linux-headers-xxx/include/linux/sched.h 文件中可以查看struct task_struct 结构体定义。其内部成员有很多,我们只需要掌握以下部分即可:
⚫ 进程id:系统中每个进程有唯一的id,用pid_t 类型表示,其实就是一个非负整数
⚫ 进程的状态:有就绪、运行、挂起、停止等状态
进程切换时需要保存和恢复的一些CPU寄存器
⚫ 描述虚拟地址空间的信息
⚫ 描述控制终端的信息

⚫ 当前工作目录(Current Working Directory)
⚫ umask 掩码(就是为了抹掉一些权限的)
文件描述符表,包含很多指向file 结构体的指针
⚫ 和信号相关的信息
⚫ 用户id 和组id
⚫ 会话(Session)和进程组
⚫ 进程可以使用的资源上限(Resource Limit)

ulimit -a	# 可以查看进程使用的资源上限

2.2 进程状态转换*

01 / 进程的状态

进程状态反映进程执行过程的变化。这些状态随着进程的执行和外界条件的变化而转换。在三态模型中,进程状态分为三个基本状态,即就绪态,运行态,阻塞态。在五态模型中,进程分为新建态、就绪态,运行态,阻塞态,终止态。

  • 运行态:进程占有处理器正在运行
  • 就绪态:进程具备运行条件,等待系统分配处理器以便运行。当进程已分配到除CPU以外的所有必要资源后,只要再获得CPU,便可立即执行。在一个系统中处于就绪状态的进程可能有多个,通常将它们排成一个队列,称为就绪队列
  • 阻塞态:又称为等待(wait)态或睡眠(sleep)态,指进程不具备运行条件,正在等待某个事件的完成

image-20230204120843188

  • 新建态:进程刚被创建时的状态,尚未进入就绪队列
  • 终止态:进程完成任务到达正常结束点,或出现无法克服的错误而异常终止,或被操作系统及有终止权的进程所终止时所处的状态。进入终止态的进程以后不再执行,但依然保留在操作系统中等待善后一旦其他进程完成了对终止态进程的信息抽取之后,操作系统将删除该进程

image-20230204121233873

02 / 进程相关命令

查看进程

ps aux / ajx

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

image-20230204121849421

image-20230204121930713

STAT参数意义

D 不可中断Uninterruptible(usually IO)
R 正在运行,或在队列中的进程
S(大写) 处于休眠状态
T 停止或被追踪
Z 僵尸进程
W 进入内存交换(从内核2.6开始无效)
X 死掉的进程
< 高优先级
N 低优先级
s 包含子进程
+ 位于前台的进程组

实时显示进程动态

top

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

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

杀死进程

kill [-signal] pid
kill –l 列出所有信号
kill –SIGKILL 进程ID
kill -9 进程ID
killall name 根据进程名杀死进程
./a.out & 		# 可以在后台运行,不会抢占会话(可以输入命令)

03 / 进程号和相关函数

  • 每个进程都由进程号来标识,其类型为pid_t(整型),进程号的范围:0~32767。进程号总是唯一的,但可以重用。当一个进程终止后,其进程号就可以再次使用。

  • 任何进程(除init 进程)都是由另一个进程创建,该进程称为被创建进程的父进程,对应的进程号称为父进程号(PPID)。

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

  • 进程号和进程组相关函数:

    pid_t getpid(void);			// 查看进程ID
    pid_t getppid(void);		// 查看父进程ID
    pid_t getpgid(pid_t pid);	// 查看组ID

2.3 进程创建

01 / 进程创建

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

image-20230222103040619

#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
    返回值:
        成功:子进程中返回0,父进程中返回子进程ID
        失败:返回-1
    失败的两个主要原因:
    1. 当前系统的进程数已经达到了系统规定的上限,这时errno 的值被设置为EAGAIN
    2. 系统内存不足,这时errno 的值被设置为ENOMEM
/*
    #include <sys/types.h>
    #include <unistd.h>

    pid_t fork(void);
        函数的作用:用于创建子进程。
        返回值:
            fork()的返回值会返回两次。一次是在父进程中,一次是在子进程中。
            在父进程中返回创建的子进程的ID,
            在子进程中返回0
            如何区分父进程和子进程:通过fork的返回值。
            在父进程中返回-1,表示创建子进程失败,并且设置errno

        父子进程之间的关系:
        区别:
            1.fork()函数的返回值不同
                父进程中: >0 返回的子进程的ID
                子进程中: =0
            2.pcb中的一些数据
                当前的进程的id pid
                当前的进程的父进程的id ppid
                信号集

        共同点:
            某些状态下:子进程刚被创建出来,还没有执行任何的写数据的操作
                - 用户区的数据相同
                - 文件描述符表相同
        
        父子进程对变量是不是共享的?
            - 刚开始的时候,是一样的,共享的。如果修改了数据,不共享了。
            - 读时共享(子进程被创建,两个进程没有做任何的写的操作),写时拷贝。
*/

#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>

int main() {

    int num = 10;

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

    // 判断是父进程还是子进程
    if(pid > 0) {
        // printf("pid : %d\n", pid);
        // 如果大于0,返回的是创建的子进程的进程号,当前是父进程
        printf("i am parent process, pid : %d, ppid : %d\n", getpid(), getppid());

        printf("parent num : %d\n", num);
        num += 10;
        printf("parent num += 10 : %d\n", num);


    } else if(pid == 0) {
        // 当前是子进程
        printf("i am child process, pid : %d, ppid : %d\n", getpid(),getppid());
       
        printf("child num : %d\n", num);
        num += 100;
        printf("child num += 100 : %d\n", num);
    }

    // for循环
    for(int i = 0; i < 3; i++) {
        printf("i : %d , pid : %d\n", i , getpid());
        sleep(1);
    }

    return 0;
}

/*
实际上,更准确来说,Linux 的 fork() 使用是通过写时拷贝 (copy- on-write) 实现。
写时拷贝是一种可以推迟甚至避免拷贝数据的技术。
内核此时并不复制整个进程的地址空间,而是让父子进程共享同一个地址空间。
只用在需要写入的时候才会复制地址空间,从而使各个进行拥有各自的地址空间。
也就是说,资源的复制是在需要写入的时候才会进行,在此之前,只有以只读方式共享。
注意:fork之后父子进程共享文件,
fork产生的子进程与父进程相同的文件文件描述符指向相同的文件表,引用计数增加,共享文件偏移指针。
*/

#include<sys/types.h>
#include<unistd.h>
#include<stdio.h>
int main(){
    int num=10;
    //创建子进程
    pid_t pid =fork();
    //判断是父进程还是子进程
    if(pid>0){
        printf("pid: %d\n",pid);
         // 如果大于0,返回的是创建的子进程的进程号,当前是父进程
         printf("我是父进程:pid = %d, ppid = %d\n", getpid(), getppid());
    }else if(pid==0){
        //当前子进程   
        printf("我是子进程:pid = %d, ppid = %d\n", getpid(), getppid());   
    }
    for(int i=0;i<10000;i++){
        printf("i:%d,pid: %d\n",i,getpid());
        //sleep(1);
    }
}

#include <stdio.h>
#include <unistd.h>
int main() {
    printf("begin\n");
    if(fork() > 0) {
        printf("我是父进程:pid = %d, ppid = %d\n", getpid(), getppid());
        int i;
        for(i = 0; i < 10; i++) {
            printf("i = %d\n", i);
            sleep(1);
        }
    } else {
        printf("我是子进程:pid = %d, ppid = %d\n", getpid(), getppid());
        int j;
        for(j = 0; j < 10; j++) {
            printf("j = %d\n", j);
            sleep(1);
        }
    }
    return 0;
}

02 / 父子进程虚拟地址空间

image-20230205114130266

image-20230205115424459

03 / GDB 多进程调试

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

设置调试父进程或者子进程:set follow-fork-mode [parent(默认)| child]
设置调试模式:set detach-on-fork [on | off]		# 默认为on,表示调试当前进程的时候,其它的进程继续运行,如果为off,调试当前进程的时候,其它进程被GDB 挂起。
查看调试的进程:info inferiors
切换当前调试的进程:inferior id
使进程脱离GDB 调试:detach inferiors id
#include <stdio.h>
#include <unistd.h>
int main() {
    printf("begin\n");
    if(fork() > 0) {
        
        printf("我是父进程:pid = %d, ppid = %d\n", getpid(), getppid());
        int i;
        for(i = 0; i < 10; i++) {
            printf("i = %d\n", i);
            sleep(1);
        }
    } else {
        
        printf("我是子进程:pid = %d, ppid = %d\n", getpid(), getppid());
        int j;
        for(j = 0; j < 10; j++) {
            printf("j = %d\n", j);
            sleep(1);
        }
    }
    return 0;
}

image-20230205162154916调试子进程,父进程挂起

image-20230205162220059切换到父进程

image-20230205162305467进程脱离GDB调试

image-20230205162316014

image-20230205162322557

image-20230205162333357

2.4 exec函数族

一般用的是父进程创建子进程,在子进程中调用exec函数族的函数,用新的数据替换之前的用户区的数据

01 / exec 函数族介绍

exec 函数族的作用是根据指定的文件名找到可执行文件,并用它来取代调用进程的内容,换句话说,就是在调用进程内部执行一个可执行文件
exec 函数族的函数执行成功后不会返回因为调用进程的实体,包括代码段,数据段和堆栈等都已经被新的内容取代,只留下进程ID 等一些表面上的信息仍保持原样,颇有些神似“三十六计”中的“金蝉脱壳”。看上去还是旧的躯壳,却已经注入了新的灵魂。只有调用失败了,它们才会返回-1,从原程序的调用点接着往下执行。

02 / exec 函数族作用图解

image-20230205125838570

03 / exec 函数族

int execl(const char *path, const char *arg, .../* (char *) NULL */);		//用的较多
int execlp(const char *file, const char *arg, ... /* (char *) NULL */);		//用的较多
int execle(const char *path, const char *arg, .../*, (char *) NULL, char *const envp[] */);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);
int execve(const char *filename, char *const argv[], char *const envp[]);
l(list) 参数地址列表,以空指针结尾
v(vector) 存有各参数地址的指针数组的地址
p(path) 按PATH 环境变量指定的目录搜索可执行文件
e(environment) 存有环境变量字符串地址的指针数组的地址
/*  
    #include <unistd.h>
    int execl(const char *path, const char *arg, ...);
        - 参数:
            - path:需要指定的执行的文件的路径或者名称
                a.out /home/nowcoder/a.out 推荐使用绝对路径
                ./a.out hello world

            - arg:是执行可执行文件所需要的参数列表
                第一个参数一般没有什么作用,为了方便,一般写的是执行的程序的名称
                从第二个参数开始往后,就是程序执行所需要的的参数列表。
                参数最后需要以NULL结束(哨兵)

        - 返回值:
            只有当调用失败,才会有返回值,返回-1,并且设置errno
            如果调用成功,没有返回值。
*/
#include <unistd.h>
#include <stdio.h>
int main() {
    // 创建一个子进程,在子进程中执行exec函数族中的函数
    pid_t pid = fork();
    
    if(pid > 0) {
        // 父进程
        printf("i am parent process, pid : %d\n",getpid());
        sleep(1);
    }else if(pid == 0) {
        // 子进程
        // execl("hello","hello",NULL);
        execl("/bin/ps", "ps", "aux", NULL);
        perror("execl");
        printf("i am child process, pid : %d\n", getpid());	//不会输出了,子进程执行exec后面的代码会失效
    }

    for(int i = 0; i < 3; i++) {
        printf("i = %d, pid = %d\n", i, getpid());
    }
    return 0;
}
/*  
    #include <unistd.h>
    int execlp(const char *file, const char *arg, ... );
        - 会到环境变量中查找指定的可执行文件,如果找到了就执行,找不到就执行不成功。
        查看环境变量 env | grep PATH(PATH=/root/.vscode-server/bin/da76f93349a72022ca4670c1b84860304616aaa2/bin/remote-cli:/usr/local/jdk1.8.0_171/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/root/bin)
        - 参数:
            - file:需要执行的可执行文件的文件名
                a.out
                ps

            - arg:是执行可执行文件所需要的参数列表
                第一个参数一般没有什么作用,为了方便,一般写的是执行的程序的名称
                从第二个参数开始往后,就是程序执行所需要的的参数列表。
                参数最后需要以NULL结束(哨兵)

        - 返回值:
            只有当调用失败,才会有返回值,返回-1,并且设置errno
            如果调用成功,没有返回值。


        int execv(const char *path, char *const argv[]);
        argv是需要的参数的一个字符串数组
        char * argv[] = {"ps", "aux", NULL};
        execv("/bin/ps", argv);

        int execve(const char *filename, char *const argv[], char *const envp[]);
        char * envp[] = {"/home/nowcoder", "/home/bbb", "/home/aaa"};
*/
#include <unistd.h>
#include <stdio.h>

int main() {


    // 创建一个子进程,在子进程中执行exec函数族中的函数
    pid_t pid = fork();

    if(pid > 0) {
        // 父进程
        printf("i am parent process, pid : %d\n",getpid());
        sleep(1);
    }else if(pid == 0) {
        // 子进程
        execlp("ps", "ps", "aux", NULL);//会去环境变量里面找
        printf("i am child process, pid : %d\n", getpid());	//不会输出了,子进程执行exec后面的代码会失效
    }

    for(int i = 0; i < 3; i++) {
        printf("i = %d, pid = %d\n", i, getpid());
    }
    return 0;
}

image-20230205161837245

2.5 进程控制

01 / 进程退出

image-20230205162922857

/*
    #include <stdlib.h>      C库函数
    void exit(int status);

    #include <unistd.h>      系统函数
    void _exit(int status);

    status参数:是进程退出时的一个状态信息。父进程回收子进程资源的时候可以获取到。
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
    printf("hello\n");   //\n会刷新缓冲区
    printf("world");     //没有\n   系统函数不会刷新缓冲区
    // exit(0);
    _exit(0);
    return 0;
}

02 / 孤儿进程

  • 父进程运行结束,但子进程还在运行(未运行结束),这样的子进程就称为孤儿进程(Orphan Process)。
  • 每当出现一个孤儿进程的时候,内核就把孤儿进程的父进程设置为init ,而init进程会循环地wait() 它的已经退出的子进程。这样,当一个孤儿进程凄凉地结束了其生命周期的时候,init 进程就会代表党和政府出面处理它的一切善后工作。
  • 因此孤儿进程并不会有什么危害。
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
int main() {
    // 创建子进程
    pid_t pid = fork();

    // 判断是父进程还是子进程
    if(pid > 0) {
        printf("i am parent process, pid : %d, ppid : %d\n", getpid(), getppid());	//父进程很快结束
    } else if(pid == 0) {
        sleep(1);
        // 当前是子进程
        printf("i am child process, pid : %d, ppid : %d\n", getpid(),getppid());
}

    // for循环
    for(int i = 0; i < 3; i++) {
        printf("i : %d , pid : %d\n", i , getpid());
    }
    return 0;
}

image-20230205164233596

03 / 僵尸进程

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

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

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

    #include <sys/types.h>
    #include <unistd.h>
    #include <stdio.h>
    int main() {
        // 创建子进程
        pid_t pid = fork();
    
        // 判断是父进程还是子进程
        if(pid > 0) {		
            while(1) {			//父进程一直在这循环,没有回收子进程的内核区数据
                printf("i am parent process, pid : %d, ppid : %d\n", getpid(), getppid());
                sleep(1);
            }
        } else if(pid == 0) {
            // 当前是子进程
            printf("i am child process, pid : %d, ppid : %d\n", getpid(),getppid());
        }
    
        // for循环
        for(int i = 0; i < 3; i++) {
            printf("i : %d , pid : %d\n", i , getpid());
        }
        return 0;
    }

04 / 进程回收

  • 每个进程退出的时候内核释放该进程所有的资源、包括打开的文件、占用的内存等。但是仍然为其保留一定的信息,这些信息主要指进程控制块PCB的信息(包括进程号、退出状态、运行时间等)
  • 父进程可以通过调用wait或waitpid得到它的退出状态同时彻底清除掉这个进程
  • wait() 和waitpid() 函数的功能一样,区别在于,wait() 函数会阻塞,waitpid() 可以设置不阻塞waitpid() 还可以指定等待哪个子进程结束
  • 注意:一次wait或waitpid调用只能清理一个子进程,清理多个子进程应使用循环。
/*
    #include <sys/types.h>
    #include <sys/wait.h>
    pid_t wait(int *wstatus);
        功能:等待任意一个子进程结束,如果任意一个子进程结束了,此函数会回收子进程的资源。
        参数:int *wstatus
            进程退出时的状态信息,传入的是一个int类型的地址,传出参数。
        返回值:
            - 成功:返回被回收的子进程的id
            - 失败:-1 (所有的子进程都结束,调用函数失败)

    调用wait函数的进程会被挂起(阻塞),直到它的一个子进程退出或者收到一个不能被忽略的信号时才被唤醒(相当于继续往下执行)
    如果没有子进程了,函数立刻返回,返回-1;如果子进程都已经结束了,也会立即返回,返回-1.
*/
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main() {
    // 有一个父进程,创建5个子进程(兄弟)
    pid_t pid;

    // 创建5个子进程
    for(int i = 0; i < 5; i++) {
        pid = fork();
        if(pid == 0) {		//子进程暂时什么都不做
            break;
        }
    }

    if(pid > 0) {
        // 父进程
        while(1) {
            printf("parent, pid = %d\n", getpid());

            // int ret = wait(NULL);
            int st;
            int ret = wait(&st);  //会阻塞
            if(ret == -1) break;

            if(WIFEXITED(st)) {
                // 是不是正常退出
                printf("退出的状态码:%d\n", WEXITSTATUS(st));		//WEXITSTATUS(st)返回子进程exit(0);中的0
            }
            if(WIFSIGNALED(st)) {
                // 是不是异常终止
                printf("被哪个信号干掉了:%d\n", WTERMSIG(st));
            }
            printf("child die, pid = %d\n", ret);

            sleep(1);
        }

    } else if (pid == 0){
        // 子进程
         while(1) {		//在这个代码中无法自己终止,我们ps aux看一下子进程然后kill -9 子进程id杀死
            printf("child, pid = %d\n",getpid());    
            sleep(1);       
         }
        exit(0);
    }
    return 0; // exit(0)
}
/*
    #include <sys/types.h>
    #include <sys/wait.h>
    pid_t waitpid(pid_t pid, int *wstatus, int options);
        功能:回收指定进程号的子进程,可以设置是否阻塞。
        参数:
            - pid:
                pid > 0 : 某个子进程的pid
                pid = 0 : 回收当前进程组的所有子进程    
                pid = -1 : 回收所有的子进程,相当于 wait()  (最常用)
                pid < -1 : 某个进程组的组id的绝对值,回收指定进程组中的子进程
            - options:设置阻塞或者非阻塞
                0 : 阻塞
                WNOHANG : 非阻塞
            - 返回值:
                > 0 : 返回子进程的id
                = 0 : options=WNOHANG, 表示还有子进程活着
                = -1 :错误,或者没有子进程了
*/
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main() {

    // 有一个父进程,创建5个子进程(兄弟)
    pid_t pid;

    // 创建5个子进程
    for(int i = 0; i < 5; i++) {
        pid = fork();
        if(pid == 0)  break; 
    }

    if(pid > 0) {
        // 父进程
        while(1) {
            printf("parent, pid = %d\n", getpid());
            sleep(1);

            int st;
            // int ret = waitpid(-1, &st, 0);//阻塞
            int ret = waitpid(-1, &st, WNOHANG);//非阻塞

            if(ret == -1) {
                break;
            } else if(ret == 0) {
                // 说明还有子进程存在
                continue;
            } else if(ret > 0) {		//回收了一个子进程
                if(WIFEXITED(st)) {
                    // 是不是正常退出
                    printf("退出的状态码:%d\n", WEXITSTATUS(st));
                }
                if(WIFSIGNALED(st)) {
                    // 是不是异常终止
                    printf("被哪个信号干掉了:%d\n", WTERMSIG(st));
                }
                printf("child die, pid = %d\n", ret);
            }
        }

    } else if (pid == 0){
        // 子进程
         while(1) {
            printf("child, pid = %d\n",getpid());    
            sleep(1);       
         }
        exit(0);
    }
    return 0; 
}

05 / 退出信息相关宏函数

WIFEXITED(status)0,进程正常退出
WEXITSTATUS(status) 如果上宏为真,获取进程退出的状态(exit的参数)
WIFSIGNALED(status)0,进程异常终止
WTERMSIG(status) 如果上宏为真,获取使进程终止的信号编号
WIFSTOPPED(status)0,进程处于暂停状态
WSTOPSIG(status) 如果上宏为真,获取使进程暂停的信号的编号
WIFCONTINUED(status)0,进程暂停后已经继续运行

2.6 进程间通信*

01 / 进程间通讯概念

  • 进程是一个独立的资源分配单元,不同进程(这里所说的进程通常指的是用户进程)之间的资源是独立的,没有关联,不能在一个进程中直接访问另一个进程的资源。
  • 但是,进程不是孤立的,不同的进程需要进行信息的交互和状态的传递等,因此需要进程间通信( IPC:Inter Processes Communication )。
  • 进程间通信的目的:
    • 数据传输:一个进程需要将它的数据发送给另一个进程。
    • 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
    • 资源共享:多个进程之间共享同样的资源。为了做到这一点,需要内核提供互斥和同步机制。
    • 进程控制:有些进程希望完全控制另一个进程的执行(如Debug 进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。

02 / Linux 进程间通信的方式

image-20230206111045753

2.7 匿名管道

  • 管道也叫无名(匿名)管道,它是是UNIX 系统IPC(进程间通信)的最古老形式,所有的UNIX 系统都支持这种通信机制。
  • 统计一个目录中文件的数目命令:ls | wc –l,为了执行该命令,shell 创建了两个进程来分别执行lswc

image-20230206111302480

01 / 管道的特点

  • 管道其实是一个在内核内存中维护的缓冲器,这个缓冲器的存储能力是有限的,不同的操作系统大小不一定相同。
  • 管道拥有文件的特质:读操作、写操作匿名管道没有文件实体(用于有关系的进程通信,如父子进程),有名管道有文件实体,但不存储数据(可以用于没有关系的进程通信,如两个独立的进程)。可以按照操作文件的方式对管道进行操作。
  • 一个管道是一个字节流,使用管道时不存在消息或者消息边界的概念,从管道读取数据的进程可以读取任意大小的数据块,而不管写入进程写入管道的数据块的大小是多少
  • 通过管道传递的数据是顺序的,从管道中读取出来的字节的顺序和它们被写入管道的顺序是完全一样的。
  • 在管道中的数据的传递方向是单向的,一端用于写入,一端用于读取,管道是半双工的(就像打电话一样,说的时候不能听)
  • 从管道读数据是一次性操作,数据一旦被读走,它就从管道中被抛弃,释放空间以便写更多的数据,在管道中无法使用lseek() 来随机的访问数据。
  • 匿名管道只能在具有公共祖先的进程(父进程与子进程,或者两个兄弟进程,具有亲缘关系)之间使用
  • 管道数据结构:环形队列

image-20230206111642458

为什么可以使用管道进行进程间通信

image-20230206112124643

管道的数据结构

image-20230206112218361

9pipe

02 / 匿名管道的使用

  1. 创建匿名管道
#include <unistd.h>
int pipe(int pipefd[2]);
  1. 查看管道缓冲大小命令
ulimit –a
  1. 查看管道缓冲大小函数
#include <unistd.h>
long fpathconf(int fd, int name);

int pipefd[2];
int ret = pipe(pipefd);
// 获取管道的大小
long size = fpathconf(pipefd[0], _PC_PIPE_BUF);
printf("pipe size : %ld\n", size);

image-20230206113455272

    #include <unistd.h>
    int pipe(int pipefd[2]);
        功能:创建一个匿名管道,用来进程间通信。
        参数:int pipefd[2] 这个数组是一个传出参数。
            pipefd[0] 对应的是管道的读端
            pipefd[1] 对应的是管道的写端
        返回值:
            成功 0
            失败 -1
    管道默认是阻塞的:如果管道中没有数据,read阻塞,如果管道满了,write阻塞
    注意:匿名管道只能用于具有关系的进程之间的通信(父子进程,兄弟进程)
  1. 子进程发送数据给父进程
// 子进程发送数据给父进程,父进程读取到数据输出
#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(){
    //1.创建管道
    int pipefd[2];
    int ret= pipe(pipefd); //<unistd.h>
    if(ret==-1){   //管道创建失败打印失败原因退出程序
        perror("pipe");  //<stdio.h>
        exit(-1);//#include <stdlib.h>
    }
    
    //2.创建子进程
    pid_t pid = fork();  //<unistd.h>
    if(pid==-1){   //子进程创建失败打印失败原因退出程序
        perror("fork");
        exit(0);
    }
    if(pid>0){  //如果是父进程返回值就是子进程id
        //3.在父进程收数据
        close(pipefd[1]);   //关闭写端
        printf("我是父进程:%d\n",getpid());
        char buf[1024]={0};
        while(1){
            int len=read(pipefd[0],buf,sizeof(buf));   //默认阻塞
            printf("老爸收到的内容:%s,  老爸的pid:%d\n",buf,getpid());
            //sleep(1);
        }
    }else if(pid==0){//如果是子进程返回值就是0
         //4.在子进程发数据
        close(pipefd[0]);  //关闭读端<unistd.h>
        printf("我是子进程:%d\n     ",getpid());
        for(int i=0;i<100;i++){
            char str[1024]={0};
            sprintf(str,"我是你儿子  长江%d号",i);
            write(pipefd[1],str,strlen(str));
            sleep(1);
        }
    }
    return 0;
}
    // 获取管道的大小
    long size = fpathconf(pipefd[0], _PC_PIPE_BUF);
    printf("pipe size : %ld\n", size);
  1. 实现 ps aux | grep xxx 父子进程间通信
/*
2.实现 ps aux | grep xxx 父子进程间通信
    子进程: ps aux, 子进程结束后,将数据发送给父进程
    父进程:获取到数据,过滤
    pipe()
    execlp()
    子进程将标准输出 stdout_fileno 重定向到管道的写端。  dup2
*/
#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <wait.h>

int main() {
    // 创建一个管道
    int fd[2];
    int ret = pipe(fd);
    if(ret == -1) { perror("pipe");   exit(0); }
    // 创建子进程
    pid_t pid = fork();

    if(pid > 0) {
        // 父进程
        // 关闭写端
        close(fd[1]);
        // 从管道中读取
        char buf[1024] = {0};
        int len = -1;
        while((len = read(fd[0], buf, sizeof(buf) - 1)) > 0) {
            // 过滤数据输出
            printf("%s", buf);
            memset(buf, 0, 1024);   //清除数据
        }
        wait(NULL);   //回收子进程
        
    } else if(pid == 0) {
        // 子进程
        // 关闭读端
        close(fd[0]);

        // 文件描述符的重定向 stdout_fileno -> fd[1]	// stdout_fileno 指向 fd[1]所指的地址
        dup2(fd[1], STDOUT_FILENO);
        // 执行 ps aux
        execlp("ps", "ps", "aux", NULL);  //如果内容超过4K的话,只会写4K,在这里应该循环的写数据
        perror("execlp");
        exit(0);
    } else {
        perror("fork");
        exit(0);
    }
    return 0;
}
  1. 管道的读写特点:

使用管道时,需要注意以下几种特殊的情况(假设都是阻塞I/O操作)

  • 1.所有的指向管道写端的文件描述符都关闭了(管道写端引用计数为0),有进程从管道的读端读数据,那么管道中剩余的数据被读取以后,再次read会返回0,就像读到文件末尾一样。

  • 2.如果有指向管道写端的文件描述符没有关闭(管道的写端引用计数大于0),而持有管道写端的进程也没有往管道中写数据,这个时候有进程从管道中读取数据,那么管道中剩余的数据被读取后,再次read会阻塞,直到管道中有数据可以读了才读取数据并返回。

  • 3.如果所有指向管道读端的文件描述符都关闭了(管道的读端引用计数为0),这个时候有进程向管道中写数据,那么该进程会收到一个信号SIGPIPE, 通常会导致进程异常终止。

  • 4.如果有指向管道读端的文件描述符没有关闭(管道的读端引用计数大于0),而持有管道读端的进程也没有从管道中读数据,这时有进程向管道中写数据,那么在管道被写满的时候再次write会阻塞,直到管道中有空位置才能再次写入数据并返回。

  • 总结:

    • 读管道:

      • 管道中有数据,read返回实际读到的字节数。

      • 管道中无数据:

        • 写端被全部关闭,read返回0(相当于读到文件的末尾)
        • 写端没有完全关闭,read阻塞等待(如果非阻塞返回-1)
    • 写管道:

      • 管道读端全部被关闭,进程异常终止(进程收到SIGPIPE信号)

      • 管道读端没有全部关闭:

        ​ 管道已满,write阻塞

        ​ 管道没有满,write将数据写入,并返回实际写入的字节数

2.8 有名管道

01 / 有名管道特点

有名管道有文件实体,但是不存储数据

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

02 / 有名管道的使用

  • 通过命令创建有名管道
mkfifo 管道文件名字	# 创建fifo管道文件
echo "hello world" >> 管道文件名字		# 会失败
  • 通过函数创建有名管道
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
  • 一旦使用mkfifo 创建了一个FIFO,就可以使用open 打开它,常见的文件I/O 函数都可用于fifo。如:close、read、write、unlink 等。

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

    1.创建管道文件

/*
    创建fifo文件
    1.通过命令: mkfifo 名字
    2.通过函数:int mkfifo(const char *pathname, mode_t mode);

    #include <sys/types.h>
    #include <sys/stat.h>
    int mkfifo(const char *pathname, mode_t mode);
        参数:
            - pathname: 管道名称的路径
            - mode: 文件的权限 和 open 的 mode 是一样的
                    是一个八进制的数
        返回值:成功返回0,失败返回-1,并设置错误号
*/

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <stdlib.h>
#include <unistd.h>

int main() {
    // 判断文件是否存在
    int ret = access("fifo1", F_OK);
    if(ret == -1) {
        printf("管道不存在,创建管道\n");
        ret = mkfifo("fifo1", 0664);
        if(ret == -1) {
            perror("mkfifo");
            exit(0);
        }       
    }
    return 0;
}

1.write

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>

// 向管道中写数据
/*
    有名管道的注意事项:
        1.一个为只读而打开一个管道的进程会阻塞,直到另外一个进程为只写打开管道
        2.一个为只写而打开一个管道的进程会阻塞,直到另外一个进程为只读打开管道

    读管道:
        管道中有数据,read返回实际读到的字节数
        管道中无数据:
            管道写端被全部关闭,read返回0,(相当于读到文件末尾)
            写端没有全部被关闭,read阻塞等待
    
    写管道:
        管道读端被全部关闭,进行异常终止(收到一个SIGPIPE信号)
        管道读端没有全部关闭:
            管道已经满了,write会阻塞
            管道没有满,write将数据写入,并返回实际写入的字节数。
*/
int main() {
    // 1.判断文件是否存在
    int ret = access("test", F_OK);
    if(ret == -1) {
        printf("管道不存在,创建管道\n");
        
        // 2.创建管道文件
        ret = mkfifo("test", 0664);
        if(ret == -1) {
            perror("mkfifo");
            exit(0);
        }       
    }

    // 3.以只写的方式打开管道
    int fd = open("test", O_WRONLY); //2.一个为只写而打开一个管道的进程会阻塞,直到另外一个进程为只读打开管道。程序才会往下走
    if(fd == -1) {
        perror("open");
        exit(0);
    }

    // 写数据
    for(int i = 0; i < 100; i++) {
        char buf[1024];
        sprintf(buf, "hello, %d\n", i);
        printf("write data : %s\n", buf);
        write(fd, buf, strlen(buf));
        sleep(1);
    }
    close(fd);
    return 0;
}

3.read

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.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);
    }

    // 读数据
    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;
}

示例:多进程有名管道通信

image-20230206125831861

写读程序。

#include<stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
int main(){
    
    //1.创建两个管道
    int ret1=access("fifo1",F_OK); //<unistd.h>The value of amode is (R_OK, W_OK, X_OK) or the existence test (F_OK).
    int ret2=access("fifo2",F_OK);
    if(ret1 ==-1){
        printf("创建管道1\n");
        ret1 = mkfifo("fifo1",0664);  //<sys/types.h> <sys/stat.h>
        if(ret1 ==-1){   //创建失败的情况退出
            perror("mkfifo");
            exit(0);  //<unistd.h>
        }
    }
    if(ret2 ==-1){
        printf("创建管道2\n");
        ret2 = mkfifo("fifo2",0664);  //<sys/types.h> <sys/stat.h>
        if(ret2 ==-1){   //创建失败的情况退出
            perror("mkfifo");
            exit(0);  //<unistd.h>
        }
    }
    
    //2.创建子进程
    pid_t id=fork();
    if(id<0) exit(0);
    if(id>0){
        
        //3.父进程负责写数据(只写的方式打开管道1)
        printf("我是A父进程,   pid:%d\n",getpid());
        int fdw = open("fifo1",O_WRONLY);  //只写<sys/types.h> <sys/stat.h> <fcntl.h>
        if(fdw==-1){    //文件打开失败
            perror("open");
            exit(0);
        }
        printf("A打开管道fifo1成功,等待写入...\n");
        
        //开始写入数据
        char buf[128];
        while (1){
            memset(buf, 0, 128);
            //获取标准输入的数据
            fgets(buf,128,stdin);
            //写入数据
            int ret=write(fdw,buf,strlen(buf));
            if(ret==-1){   //写入错误
                perror("write");
                exit(0);
            }
        }   
        wait(NULL); 
    }else if(id==0){
        //4.子进程负责读数据(只读的方式打开管道2)
        printf("我是A子进程,   pid:%d\n",getpid());
        int fdr = open("fifo2",O_RDONLY);  //只写<sys/types.h> <sys/stat.h> <fcntl.h>
        if(fdr==-1){    //文件打开失败
            perror("open");
            exit(0);
        }
        printf("A打开管道fifo2成功,等待读取数据...\n");
        
        //开始读取数据
        char buf[128];
        while (1){
            memset(buf, 0, 128);
            //读取数据
            int ret=read(fdr,buf,128);
            if(ret==0){   //写段关闭
                printf("fifo2写段关闭");
                exit(0);
            }else if(ret < 0) {
                perror("read");
                break;
            }
            printf("我是A进程的儿子,从fifo2得到的数据是:%s\n",buf);
        }
    }
    close(fdr);
    close(fdw);
    return 0;
}

读写程序

#include<stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
int main(){
    //2.创建子进程
    pid_t id=fork();
    if(id<0) exit(0);
    if(id>0){
        
        //3.父进程负责写数据(只写的方式打开管道1)
        printf("我是B父进程,   pid:%d\n",getpid());
        int fdw = open("fifo2",O_WRONLY);  //只写<sys/types.h> <sys/stat.h> <fcntl.h>
        if(fdw==-1){    //文件打开失败
            perror("open");
            exit(0);
        }
        printf("B打开管道fifo2成功,等待写入...\n");
        //开始写入数据
        char buf[128];
        while (1){
            memset(buf, 0, 128);
            //获取标准输入的数据
            fgets(buf,128,stdin);
            //写入数据
            int ret=write(fdw,buf,strlen(buf));
            if(ret==-1){   //写入错误
                perror("write");
                exit(0);
            }
        }   
        wait(NULL);
    }else if(id==0){
        //4.子进程负责读数据(只读的方式打开管道2)
        printf("我是B子进程,   pid:%d\n",getpid());

        int fdr = open("fifo1",O_RDONLY);  //只写<sys/types.h> <sys/stat.h> <fcntl.h>
        if(fdr==-1){    //文件打开失败
            perror("open");
            exit(0);
        }
        printf("B打开管道fifo1成功,等待读取数据...\n");
        //开始读取数据
        char buf[128];
        while (1){
            memset(buf, 0, 128);
            //读取数据
            int ret=read(fdr,buf,128);
            if(ret==0){   //写入错误
                printf("fifo1写段关闭");
                exit(0);
            }else if(ret < 0) {
                perror("read");
                break;
            }
            printf("我是B进程的儿子,从fifo1得到的数据是:%s\n",buf);
        }
    }
    close(fdr);
    close(fdw);
    return 0;
}
https://geyangwen-images.oss-cn-hangzhou.aliyuncs.com/2023/linux/mediaimage-20230206125831861.png
https://geyangwen-images.oss-cn-hangzhou.aliyuncs.com/2023/linux/202302062228936.png

2.9 内存映射

01 / 内存映射特点

内存映射(Memory-mapped I/O)是将磁盘文件的数据映射到内存,用户通过修改内存就能修改磁盘文件。

image-20230206222813859

虚拟地址空间对应实际的物理内存

映射的到位置就是和共享库加载的数据一样的位置。

02 / 内存映射相关系统调用

#include <sys/mman.h>
void* mmap(void *addr, size_t length, int prot, int flags,int fd, off_t offset);
int munmap(void *addr, size_t length);

/*
    #include <sys/mman.h>
    void *mmap(void *addr, size_t length, int prot, int flags,int fd, off_t offset);
        - 功能:将一个文件或者设备的数据映射到内存中
        - 参数:
            - void *addr: NULL, 由内核指定
            - length : 要映射的数据的长度,这个值不能为0。建议使用文件的长度。
					获取文件的长度:stat lseek(如果文件太小,其大小会是分页的整数倍)
            - prot : 对申请的内存映射区的操作权限
                -PROT_EXEC :可执行的权限
                -PROT_READ :读权限
                -PROT_WRITE :写权限
                -PROT_NONE :没有权限
                	要操作映射内存,必须要有读的权限。
                PROT_READ、PROT_READ|PROT_WRITE
            - flags :
                - MAP_SHARED : 映射区的数据会自动和磁盘文件进行同步,进程间通信,必须要设置这个选项
                - MAP_PRIVATE :不同步,内存映射区的数据改变了,对原来的文件不会修改,会重新创建一个新的文件。(copy on write)
            - fd: 需要映射的那个文件的文件描述符
                - 通过open得到,open的是一个磁盘文件
                - 注意:文件的大小不能为0,open指定的权限不能和prot参数有冲突。
                    prot: PROT_READ                open:只读/读写 
                    prot: PROT_READ | PROT_WRITE   open:读写
            - offset:偏移量,一般不用。必须指定的是4k的整数倍,0表示不偏移。
        - 返回值:返回创建的内存的首地址
          失败返回MAP_FAILED,(void *) -1   ——一个宏

    int munmap(void *addr, size_t length);
        - 功能:释放内存映射
        - 参数:
            - addr : 要释放的内存的首地址
            - length : 要释放的内存的大小,要和mmap函数中的length参数的值一样。
*/
1.有关系的进程(父子进程)
    - 还没有子进程的时候,通过唯一的父进程,先创建内存映射区
    - 有了内存映射区以后,创建子进程
    - 父子进程共享创建的内存映射区
2.没有关系的进程间通信
    - 准备一个大小不是0的磁盘文件
    - 进程1 通过磁盘文件创建内存映射区
        - 得到一个操作这块内存的指针
    - 进程2 通过磁盘文件创建内存映射区
        - 得到一个操作这块内存的指针
    - 使用内存映射区通信
注意:内存映射区通信,是非阻塞。
  1. 有关系的进程(父子进程)通信
#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);  // 获取文件的大小,  (从末尾去偏移0的偏移量,就是文件的大小)

    // 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);  
        printf("read data : %s\n", buf);
    }else if(pid == 0){
        // 子进程
        strcpy((char *)ptr, "nihao a, son!!!"); //copy复制,然后修改前面的内容,//相当于覆盖前面的数据
}
    // 关闭内存映射区
    munmap(ptr, size);
    return 0;
}
  1. 内存映射实现文件拷贝
// 使用内存映射实现文件拷贝的功能(不能拷贝太大的文件,有可能内存放不下,基本不会用这样的方法拷贝文件)
/*思路:
        1.对原始的文件进行内存映射
        2.创建一个新文件(拓展该文件)
        3.把新文件的数据映射到内存中
        4.通过内存拷贝将第一个文件的内存数据拷贝到新的文件内存中
        5.释放资源*/
#include <stdio.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>

int main() {

    // 1.对原始的文件进行内存映射
    int fd = open("english.txt", O_RDWR);
    if(fd == -1) {        perror("open");        exit(0);    }

    // 获取原始文件的大小
    int len = lseek(fd, 0, SEEK_END);

    // 2.创建一个新文件(拓展该文件)
    int fd1 = open("cpy.txt", O_RDWR | O_CREAT, 0664);
    if(fd1 == -1) {        perror("open");        exit(0);    }
    
    // 对新创建的文件进行拓展
    truncate("cpy.txt", len);  //新创建的文件拓展到原始文件的大小    也可以用lseek
    write(fd1, " ", 1);

    // 3.分别做内存映射
    void * ptr = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); //旧文件
    void * ptr1 = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd1, 0);

    if(ptr == MAP_FAILED) {        perror("mmap");        exit(0);    }
    if(ptr1 == MAP_FAILED) {        perror("mmap");        exit(0);    }

    // 内存拷贝
    memcpy(ptr1, ptr, len);
    
    // 释放资源
    munmap(ptr1, len); //后打开的先释放
    munmap(ptr, len);

    close(fd1);  //后打开的先关闭
    close(fd);
    return 0;
}
  1. 匿名映射
/* 匿名映射:不需要文件实体进程一个内存映射*/
#include <stdio.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/wait.h>
int main() {

    // 1.创建匿名内存映射区
    int len = 4096;  //4K的数据
    void * ptr = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);  //MAP_ANONYMOUS匿名内存映射
    if(ptr == MAP_FAILED) { perror("mmap");        exit(0);}

    // 父子进程间通信
    pid_t pid = fork();
    if(pid > 0) {
        // 父进程
        strcpy((char *) ptr, "hello, world");
        wait(NULL);
    }else if(pid == 0) {
        // 子进程
        sleep(1);
        printf("%s\n", (char *)ptr);
    }

    // 释放内存映射区
    int ret = munmap(ptr, len);
    if(ret == -1) {  perror("munmap");    exit(0); }
    return 0;
}

03 / 思考问题

  • 如果对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

  • mmap什么情况下会调用失败?

    - 第二个参数:length = 0

    - 第三个参数:prot

    ​ - 只指定了写权限

    ​ - prot PROT_READ | PROT_WRITE

    - 第5个参数fd 通过open函数时指定的 O_RDONLY / O_WRONLY

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

    - 可以的,但是创建的文件的大小如果为0的话,肯定不行

    - 可以对新的文件进行扩展

    ​ - lseek() 把文件的大小拓展到一定长度 ;移动偏移量

    ​ - truncate()

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

    int fd = open(“XXX”);

    mmap(,,,,fd,0);

    close(fd);

    映射区还存在,创建映射区的fd被关闭,没有任何影响。

  • 对ptr越界操作会怎样?

    void * ptr = mmap(NULL, 100,,,,,);

    4K

    越界操作操作的是非法的内存 -> 段错误

2.10 信号

01 / 信号的概念

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

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

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

使用信号的两个主要目的是:

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

信号的特点:

  • 简单
  • 不能携带大量信息
  • 满足某个特定条件才发送
  • 优先级比较高

查看系统定义的信号列表:kill –l
前31 个信号为常规信号,其余为实时信号。

02 / 信号预览表

image-20230207181201260

image-20230207181316840

image-20230207181335931

image-20230207181356611

03 / 信号的5 种默认处理动作

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

  • 信号的5 中默认处理动作

    • Term 终止进程
    • Ign 当前进程忽略掉这个信号
    • Core 终止进程,并生成一个Core文件
    • Stop 暂停当前进程
    • Cont 继续执行当前被暂停的进程
  • 信号的几种状态:产生、未决、递达

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

// core.c
#include <stdio.h>
#include <string.h>
int main() {
    char * buf;
    strcpy(buf, "hello"); // 错误
    return 0;
}

生成core文件步骤:

image-20230207201042976

ulimit -a          ulimit -c [1024或者unlimited]
gcc core.c -g 
./a.out
gdb a.out   
core-file core	# 调试过程输入,会告诉你错误原因

04 / 信号相关的函数

int kill(pid_t pid, int sig);
int raise(int sig);
void abort(void);
unsigned int alarm(unsigned int seconds);
int setitimer(int which, const struct itimerval *new_val, struct itimerval *old_value);
  1. 信号相关函数
/*  
    #include <sys/types.h>
    #include <signal.h>

    int kill(pid_t pid, int sig);
        - 功能:给任何的进程或者进程组pid, 发送任何的信号 sig
        - 参数:
            - pid :
                > 0 : 将信号发送给指定的进程
                = 0 : 将信号发送给当前的进程组
                = -1 : 将信号发送给每一个有权限接收这个信号的进程
                < -1 : 这个pid=某个进程组的ID取反 (-12345)
            - sig : 需要发送的信号的编号或者是宏值,推荐宏值,0表示不发送任何信号
        kill(getppid(), 9);
        kill(getpid(), 9);
        
    int raise(int sig);
        - 功能:给当前进程或者线程发送信号
        - 参数:
            - sig : 要发送的信号
        - 返回值:
            - 成功 0
            - 失败 非0
        kill(getpid(), sig);   用kill实现raise

    void abort(void);
        - 功能: 发送SIGABRT信号给当前的进程,杀死当前进程
        kill(getpid(), SIGABRT);	用kill实现abort
*/

#include <stdio.h>
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>
int main() {
    pid_t pid = fork();
    if(pid == 0) {
        // 子进程
        int i = 0;
        for(i = 0; i < 5; i++) {
            printf("child process\n");
            sleep(1);
        }
    } else if(pid > 0) {
        // 父进程
        printf("parent process\n");
        sleep(2);
        printf("kill child process now\n");
        kill(pid, SIGINT);		//杀死子进程,pid是子进程的pid
    }
    return 0;
}
  1. 定时器相关函数alarm、setitimer
/*
    #include <unistd.h>
    unsigned int alarm(unsigned int seconds);
        - 功能:设置定时器(闹钟)。函数调用,开始倒计时,当倒计时为0的时候,函数会给当前的进程发送一个信号:SIGALARM
        - 参数:
            seconds: 倒计时的时长,单位:秒。如果参数为0,定时器无效(不进行倒计时,不发信号)。取消一个定时器,通过alarm(0)。
        - 返回值:
            - 之前没有定时器,返回0
            - 之前有定时器,返回之前的定时器剩余的时间

    - SIGALARM :默认终止当前的进程,每一个进程都有且只有唯一的一个定时器。
        alarm(10);  -> 返回0
        过了1秒
        alarm(5);   -> 返回9,  会取消之前的定时器,5秒之后终止进程
	    alarm(100) -> 该函数是不阻塞的
*/

#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;
}
// 1秒钟电脑能数多少个数?
#include <stdio.h>
#include <unistd.h>
/*
    实际的时间 = 内核时间 + 用户时间 + 消耗的时间
    进行文件IO操作的时候比较浪费时间
    定时器,与进程的状态无关(自然定时法)。无论进程处于什么状态,alarm都会计时。
*/
int main() {    
    alarm(1);
    int i = 0;
    while(1) {
        printf("%i\n", i++);
    }
    return 0;
}   //./alarm1 >> a.txt,写到文件里面会更多
/*
    #include <sys/time.h>
    int setitimer(int which, const struct itimerval *new_value, 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;    //  微秒    
                };

            过10秒后,每个2秒定时一次
            - old_value :记录上一次的定时的时间参数,一般不使用,指定NULL
        - 返回值:
            成功 0
            失败 -1 并设置错误号
*/

#include <sys/time.h>
#include <stdio.h>
#include <stdlib.h>

// 过3秒以后,每隔2秒钟定时一次
int main() {
    struct itimerval new_value;

    // 设置间隔的时间
    new_value.it_interval.tv_sec = 2;      //秒数
    new_value.it_interval.tv_usec = 0;     //微秒数

    // 设置延迟的时间,3秒之后开始第一次定时
    new_value.it_value.tv_sec = 3;
    new_value.it_value.tv_usec = 0;

    int ret = setitimer(ITIMER_REAL, &new_value, NULL); // 非阻塞的
    printf("定时器开始了...\n");		//执行程序立马打印这个,过3秒打印闹钟,没有捕捉该信号默认会结束该进程

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

    getchar();  // 阻塞的
    return 0;
}

image-20230207204638246

05 / 信号捕捉函数

sighandler_t signal(int signum, sighandler_t handler);
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
/*
    #include <signal.h>
    typedef void (*sighandler_t)(int);
    sighandler_t signal(int signum, sighandler_t handler);	,,尽量用sigaction
        - 功能:设置某个信号的捕捉行为
        - 参数:
            - signum: 要捕捉的信号
            - handler: 捕捉到信号要如何处理
                - SIG_IGN : 忽略信号
                - SIG_DFL : 使用信号默认的行为
                - 回调函数 :  这个函数是内核调用,程序员只负责写,捕捉到信号后如何去处理信号。
             回调函数:
                 - 需要程序员实现,提前准备好的,函数的类型根据实际需求,看函数指针的定义
                 - 不是程序员调用,而是当信号产生,由内核调用
                 - 函数指针是实现回调的手段,函数实现之后,将函数名放到函数指针的位置就可以了。
        - 返回值:
            成功,返回上一次注册的信号处理函数的地址。第一次调用返回NULL
            失败,返回SIG_ERR,设置错误号
    SIGKILL SIGSTOP不能被捕捉,不能被忽略。
*/
#include <sys/time.h>
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>

void myalarm(int num) {
    printf("捕捉到了信号的编号是:%d\n", num);
    printf("xxxxxxx\n");
}

// 过3秒以后,每隔2秒钟定时一次
int main() {

    // 注册信号捕捉(要产生信号之前注册信号捕捉函数)
    // signal(SIGALRM, SIG_IGN);    //忽略信号
    // signal(SIGALRM, SIG_DFL);    //默认,是终止进程
// void (*sighandler_t)(int); 函数指针,int类型的参数表示捕捉到的信号的值。
    					//函数指针名称:sighandler_t    返回值:void, 函数参数:int
//void * sighandler_t(int)  指针函数   返回值:void*  函数名:sighandler_t  函数参数:int
    signal(SIGALRM, myalarm); 

	struct itimerval new_value;
    // 设置间隔的时间
    new_value.it_interval.tv_sec = 2;
    new_value.it_interval.tv_usec = 0;

    // 设置延迟的时间,3秒之后开始第一次定时
    new_value.it_value.tv_sec = 3;
    new_value.it_value.tv_usec = 0;

    int ret = setitimer(ITIMER_REAL, &new_value, NULL); // 非阻塞的
    printf("定时器开始了...\n");

    if(ret == -1) {        perror("setitimer");        exit(0);    }
    getchar();
    return 0;
}

image-20230207205924177

/*  等学完信号集再来看
    #include <signal.h>
    int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
        - 功能:检查或者改变信号的处理。信号捕捉
        - 参数:
            - signum : 需要捕捉的信号的编号或者宏值(推荐宏值)(信号的名称)
            - act :捕捉到信号之后的处理动作
            - oldact : 上一次对信号捕捉相关的设置,一般不使用,传递NULL
        - 返回值:
            成功 0
            失败 -1

     struct sigaction {
        // 函数指针,指向的函数就是信号捕捉到之后的处理函数
        void     (*sa_handler)(int);
        
        // 不常用
        void     (*sa_sigaction)(int, siginfo_t *, void *);
        
        // 临时阻塞信号集,在信号捕捉函数执行过程中,临时阻塞某些信号。
        sigset_t   sa_mask;  信号捕捉函数完了就不阻塞了
        
        // 使用哪一个信号处理对捕捉到的信号进行处理
        // 这个值可以是0,表示使用sa_handler,也可以是SA_SIGINFO表示使用sa_sigaction
        int        sa_flags;
        // 被废弃掉了
        void     (*sa_restorer)(void);
    };
*/
#include <sys/time.h>
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>

void myalarm(int num) {
    printf("捕捉到了信号的编号是:%d\n", num);
    printf("xxxxxxx\n");
}

// 过3秒以后,每隔2秒钟定时一次
int main() {
    struct sigaction act;
    act.sa_flags = 0;           //这个值可以是0,表示使用sa_handler
    act.sa_handler = myalarm;   // 函数指针,指向的函数就是信号捕捉到之后的处理函数
    sigemptyset(&act.sa_mask);  // 清空临时阻塞信号集,表示不阻塞信号
   
    // 注册信号捕捉
    sigaction(SIGALRM, &act, NULL);

    struct itimerval new_value;

    // 设置间隔的时间
    new_value.it_interval.tv_sec = 2;
    new_value.it_interval.tv_usec = 0;

    // 设置延迟的时间,3秒之后开始第一次定时
    new_value.it_value.tv_sec = 3;
    new_value.it_value.tv_usec = 0;

    int ret = setitimer(ITIMER_REAL, &new_value, NULL); // 非阻塞的
    printf("定时器开始了...\n");

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

    // getchar();
    while(1);
    return 0;
}

06 / 信号集

  • 许多信号相关的系统调用都需要能表示一组不同的信号,多个信号可使用一个称之为信号集的数据结构来表示,其系统数据类型为sigset_t。
  • 在PCB 中有两个非常重要的信号集。一个称之为“阻塞信号集” ,另一个称之为“未决信号集” 。这两个信号集都是内核使用位图机制来实现的。但操作系统不允许我们直接对这两个信号集进行位操作。而需自定义另外一个集合,借助信号集操作函数来对PCB 中的这两个信号集进行修改。
  • 信号的“未决” 是一种状态,指的是从信号的产生到信号被处理前的这一段时间。
  • 信号的“阻塞” 是一个开关动作,指的是阻止信号被处理但不是阻止信号产生
  • 信号的阻塞就是让系统暂时保留信号留待以后发送。由于另外有办法让系统忽略信号,所以一般情况下信号的阻塞只是暂时的,只是为了防止信号打断敏感的操作

image-20230207211413490

  1. 用户通过键盘 Ctrl + C, 产生2号信号SIGINT (信号被创建)

  2. 信号产生但是没有被处理 (未决)

    • 在内核中将所有的没有被处理的信号存储在一个集合中 (未决信号集)

    • SIGINT信号状态被存储在第二个标志位上

      • 这个标志位的值为0, 说明信号不是未决状态
      • 这个标志位的值为1, 说明信号处于未决状态
  3. 这个未决状态的信号,需要被处理,处理之前需要和另一个信号集(阻塞信号集),进行比较

    - 阻塞信号集默认不阻塞任何的信号

    - 如果想要阻塞某些信号需要用户调用系统的API(把对应的位设置为1就可以阻塞对应的未决信号)

  4. 在处理的时候和阻塞信号集中的标志位进行查询,看是不是对该信号设置阻塞了

    • 如果没有阻塞,这个信号就被处理
    • 如果阻塞了,这个信号就继续处于未决状态,直到阻塞解除,这个信号就被处理

07 / 信号集相关的函数

int sigemptyset(sigset_t *set);				//将信号集中的所有的标志位置为0
int sigfillset(sigset_t *set);				//将信号集中的所有的标志位置为1
int sigaddset(sigset_t *set, int signum);	//设置信号集中的某一个信号对应的标志位为1,表示阻塞这个信号
int sigdelset(sigset_t *set, int signum);	//设置信号集中的某一个信号对应的标志位为0,表示不阻塞这个信号
int sigismember(const sigset_t *set, int signum);	//判断某个信号是否阻塞
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);	//将信号集中的所有的标志位置为0
int sigpending(sigset_t *set);				//将信号集中的所有的标志位置为0

以下信号集相关的函数都是对自定义的信号集进行操作。
操作的是阻塞信号集

int sigemptyset(sigset_t *set);
    - 功能:清空信号集中的数据,将信号集中的所有的标志位置为0
    - 参数:set,传入传出参数,需要操作的信号集
    - 返回值:成功返回0, 失败返回-1

int sigfillset(sigset_t *set);
    - 功能:将信号集中的所有的标志位置为1
    - 参数:set,传出参数,需要操作的信号集
    - 返回值:成功返回0, 失败返回-1

int sigaddset(sigset_t *set, int signum);
    - 功能:设置信号集中的某一个信号对应的标志位为1,表示阻塞这个信号
    - 参数:
        - set:传出参数,需要操作的信号集
        - signum:需要设置阻塞的那个信号
    - 返回值:成功返回0, 失败返回-1

int sigdelset(sigset_t *set, int signum);
    - 功能:设置信号集中的某一个信号对应的标志位为0,表示不阻塞这个信号
    - 参数:
        - set:传出参数,需要操作的信号集
        - signum:需要设置不阻塞的那个信号
    - 返回值:成功返回0, 失败返回-1

int sigismember(const sigset_t *set, int signum);
    - 功能:判断某个信号是否阻塞
    - 参数:
        - set:需要操作的信号集
        - signum:需要判断的那个信号
    - 返回值:
        1 : signum被阻塞
        0 : signum不阻塞
       -1 : 失败
#include <signal.h>
#include <stdio.h>
int main() {

    // 创建一个信号集,清空信号集的内容
    sigset_t set;    //里面的数据可能是随机的

    // 清空信号集的内容
    sigemptyset(&set);

    // 判断 SIGINT 是否在信号集 set 里
    int ret = sigismember(&set, SIGINT);
    if(ret == 0) {
        printf("SIGINT 不阻塞\n");
    } else if(ret == 1) {
        printf("SIGINT 阻塞\n");
    }

    // 添加几个信号到信号集中
    sigaddset(&set, SIGINT);
    sigaddset(&set, SIGQUIT);

    // 判断SIGINT是否在信号集中
    ret = sigismember(&set, SIGINT);
    if(ret == 0) {
        printf("SIGINT 不阻塞\n");
    } else if(ret == 1) {
        printf("SIGINT 阻塞\n");
    }

    // 判断SIGQUIT是否在信号集中
    ret = sigismember(&set, SIGQUIT);
    if(ret == 0) {
        printf("SIGQUIT 不阻塞\n");
    } else if(ret == 1) {
        printf("SIGQUIT 阻塞\n");
    }

    // 从信号集中删除一个信号
    sigdelset(&set, SIGQUIT);

    // 判断SIGQUIT是否在信号集中
    ret = sigismember(&set, SIGQUIT);
    if(ret == 0) {
        printf("SIGQUIT 不阻塞\n");
    } else if(ret == 1) {
        printf("SIGQUIT 阻塞\n");
    }

    return 0;
}

image-20230207212401615

/*
    int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
        - 功能:将自定义信号集中的数据设置到内核中(设置阻塞,解除阻塞,替换)
        - 参数:
            - how : 如何对内核阻塞信号集进行处理
                SIG_BLOCK: 将用户设置的阻塞信号集添加到内核中,内核中原来的数据不变
                    假设内核中默认的阻塞信号集是mask, mask | set
                SIG_UNBLOCK: 根据用户设置的数据,对内核中的数据进行解除阻塞
                    mask &= ~set  ,解除的位也要设置为1
                SIG_SETMASK:覆盖内核中原来的值
            
            - set :已经初始化好的用户自定义的信号集
            - oldset : 保存设置之前的内核中的阻塞信号集的状态,可以是 NULL
        - 返回值:
            成功:0
            失败:-1
                设置错误号:EFAULT(指向错误的地址)、EINVAL(how指定非法)

    int sigpending(sigset_t *set);
        - 功能:获取内核中的未决信号集
        - 参数:set,传出参数,保存的是内核中的未决信号集中的信息。
*/

// 编写一个程序,把所有的常规信号(1-31)的未决状态打印到屏幕
// 设置某些信号是阻塞的,通过键盘产生这些信号
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
#include <unistd.h>
int main() {

    // 创建一个信号集, 并清空信号集的内容
    sigset_t set;
    sigemptyset(&set);
    // 将2号和3号信号添加到信号集中	// 设置2、3号信号阻塞
    sigaddset(&set, SIGINT);
    sigaddset(&set, SIGQUIT);

    // 修改内核中的阻塞信号集
    sigprocmask(SIG_BLOCK, &set, NULL);

    int num = 0;
    while(1) {
        num++;
        // 获取当前的未决信号集的数据
        sigset_t pendingset;   //就是一个长整型
        sigemptyset(&pendingset);
        sigpending(&pendingset);

        // 遍历前32位
        for(int i = 1; i <= 31; i++) {
            if(sigismember(&pendingset, i) == 1) {
                printf("1");  //未决状态
            }else if(sigismember(&pendingset, i) == 0) {
                printf("0");  //非未决状态
            }else {
                perror("sigismember");
                exit(0);
            }
        }

        printf("\n");
        sleep(1);
        if(num == 10) {
            // 解除阻塞
            sigprocmask(SIG_UNBLOCK, &set, NULL);
        }
    }
    return 0;
}//    ./文件名 &   后台执行     fg切换至前台

08 / 内核实现信号捕捉的过程

image-20230207214639013

09 / SIGCHLD信号

  • SIGCHLD信号产生的条件

    • 子进程终止时
    • 子进程接收到SIGSTOP 信号停止时
    • 子进程处在停止态,接受到SIGCONT后唤醒时
  • 以上三种条件都会给父进程发送SIGCHLD 信号,父进程默认会忽略该信号

/*
    SIGCHLD信号产生的3个条件:
        1.子进程结束
        2.子进程暂停了
        3.子进程继续运行
        都会给父进程发送该信号,父进程默认忽略该信号。
    
    使用SIGCHLD信号解决僵尸进程的问题。
*/
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <signal.h>
#include <sys/wait.h>

void myFun(int num) {
    printf("捕捉到的信号 :%d\n", num);
    // 回收子进程PCB的资源
    // while(1) {  //搞死循环可以回收子进程,但是父进程做不了事情
    //     wait(NULL); 
    // }
    while(1) {		//信号触发一次,这个循环可以回收几个子进程,避免僵尸进程
       int ret = waitpid(-1, NULL, WNOHANG);   //-1表示回收所有子进程 ,WNOHANG非阻塞
       if(ret > 0) {
           printf("child die , pid = %d\n", ret);
       } else if(ret == 0) {
           // 说明还有子进程活着
           break;
       } else if(ret == -1) {
           // 没有子进程
           break;
       }
    }
}

int main() {

    // 提前设置好阻塞信号集,阻塞SIGCHLD,因为有可能子进程很快结束,父进程还没有注册完信号捕捉
    sigset_t set;
    sigemptyset(&set);
    sigaddset(&set, SIGCHLD);
    sigprocmask(SIG_BLOCK, &set, NULL);

    // 创建一些子进程
    pid_t pid;
    for(int i = 0; i < 20; i++) {
        pid = fork();
        if(pid == 0) {
            break;
        }
    }

    if(pid > 0) {
        // 父进程

        // 捕捉子进程死亡时发送的SIGCHLD信号
        struct sigaction act;
        act.sa_flags = 0;
        act.sa_handler = myFun;
        sigemptyset(&act.sa_mask);  //清空
        sigaction(SIGCHLD, &act, NULL);

        // 注册完信号捕捉以后(执行完),解除阻塞
        sigprocmask(SIG_UNBLOCK, &set, NULL);

        while(1) {
            printf("parent process pid : %d\n", getpid());
            sleep(2);
        }
    } else if( pid == 0) {
        // 子进程
        printf("child process pid : %d\n", getpid());
    }

    return 0;
}

2.11 共享内存

01 / 共享内存(比内存映射效率还高)

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

  • 管道等要求发送进程将数据从用户空间的缓冲区复制进内核内存接收进程将数据从内核内存复制进用户空间的缓冲区的做法相比,这种IPC 技术的速度更快。

    管道工作示意图

02 / 共享内存使用步骤

  • 调用shmget() 创建一个新共享内存段或取得一个既有共享内存段的标识符(即由其他进程创建的共享内存段)。这个调用将返回后续调用中需要用到的共享内存标识符。
  • 使用shmat() 来附上共享内存段,即使该段成为调用进程的虚拟内存的一部分
  • 此刻在程序中可以像对待其他可用内存那样对待这个共享内存段。为引用这块共享内存,程序需要使用由shmat() 调用返回的addr 值,它是一个指向进程的虚拟地址空间中该共享内存段的起点的指针
  • 调用shmdt() 来分离共享内存段。在这个调用之后,进程就无法再引用这块共享内存了。这一步是可选的,并且在进程终止时会自动完成这一步。
  • 调用shmctl() 来删除共享内存段。只有当当前所有附加内存段的进程都与之分离之后内存段才会销毁。只有一个进程需要执行这一步。

03 / 共享内存操作函数

int shmget(key_t key, size_t size, int shmflg);
void *shmat(int shmid, const void *shmaddr, int shmflg);
int shmdt(const void *shmaddr);
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
key_t ftok(const char *pathname, int proj_id);
共享内存相关的函数
#include <sys/ipc.h>
#include <sys/shm.h>

int shmget(key_t key, size_t size, int shmflg);
    - 功能:创建一个新的共享内存段,或者获取一个既有的共享内存段的标识。
        新创建的内存段中的数据都会被初始化为0
    - 参数:
        - key : key_t类型是一个整形,通过这个找到或者创建一个共享内存。 一般使用16进制表示,非0- size: 共享内存的大小(如果设置为1,太小了,会自动做成分页的大小)
        - shmflg: 属性
            - 访问权限
            - 附加属性:创建/判断共享内存是不是存在
                - 创建:IPC_CREAT
                - 判断共享内存是否存在: IPC_EXCL , 需要和IPC_CREAT一起使用
                    IPC_CREAT | IPC_EXCL | 0664		//| 0664表示加上权限
        - 返回值:
            失败:-1 并设置错误号
            成功:>0 返回共享内存的引用的ID,后面操作共享内存都是通过这个值。

void *shmat(int shmid, const void *shmaddr, int shmflg);
    - 功能:和当前的进程进行关联
    - 参数:
        - shmid : 共享内存的标识(ID),由shmget返回值获取
        - shmaddr: 申请的共享内存的起始地址,指定NULL就行了,由内核指定
        - shmflg : 对共享内存的操作
            - 读 : SHM_RDONLY, 必须要有读权限
            - 读写: 0
    - 返回值:
        成功:返回共享内存的首(起始)地址。  失败(void *) -1

int shmdt(const void *shmaddr);
    - 功能:解除当前进程和共享内存的关联
    - 参数:
        shmaddr:共享内存的首地址
    - 返回值:成功 0, 失败 -1

int shmctl(int shmid, int cmd, struct shmid_ds *buf);
    - 功能:对共享内存进行操作。删除共享内存,共享内存要删除才会消失,
    创建共享内存的进行被销毁了对共享内存是没有任何影响。
    - 参数:
        - shmid: 共享内存的ID
        - cmd : 要做的操作
            - IPC_STAT : 获取共享内存的当前的状态
            - IPC_SET : 设置共享内存的状态
            - IPC_RMID: 标记共享内存被销毁
        - buf:需要设置或者获取的共享内存的属性信息 (传出参数)
            - IPC_STAT : buf存储数据
            - IPC_SET : buf中需要初始化数据,设置到内核中
            - IPC_RMID : 没有用,NULL

image-20230207221917524

key_t ftok(const char *pathname, int proj_id);
    - 功能:根据指定的路径名,和int值,生成一个共享内存的key
    - 参数:
        - pathname:指定一个存在的路径
            /home/nowcoder/Linux/a.txt
            / 
        - proj_id: int类型的值,但是这系统调用只会使用其中的1个字节
                   范围 : 0-255  一般指定一个字符 'a'
//write_shm
#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); //自己随便指定的100会转化为16进制的数
    printf("shmid : %d\n", shmid);
    
    // 2.和当前进程进行关联
    void * ptr = shmat(shmid, NULL, 0);  //返回共享内存在虚拟空间的首地址
    char * str = "helloworld";

    // 3.写数据
    memcpy(ptr, str, strlen(str) + 1);//加1是加上结束符
    printf("按任意键继续\n");
    getchar();

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

    // 5.删除共享内存
    shmctl(shmid, IPC_RMID, NULL);
    return 0;
}


//gyw_write_shm.c
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <string.h>

int main() {    

    // 1.创建一个共享内存
    key_t key1= ftok("/root/LinuxLearn/lesson26",'a');
    int shmid = shmget(key1,4096, IPC_CREAT| 0664);
     printf("shmid : %d\n", shmid);
    // 2.和当前进程进行关联
    void * ptr = shmat(shmid,NULL,0);
    char* str="helloworld";
    // 3.写数据
    memcpy(ptr,str,strlen(str)+1);
    printf("按任意键继续\n");
    getchar();
    // 4.解除关联
    shmdt(ptr);

    // 5.删除共享内存
    shmctl(shmid,IPC_RMID,NULL);
    
    return 0;
}
//read_shm
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <string.h>
int main() {    

    // 1.获取一个共享内存
    int shmid = shmget(100, 0, IPC_CREAT);  //100主要是和之前write里的一样
    printf("shmid : %d\n", shmid);

    // 2.和当前进程进行关联
    void * ptr = shmat(shmid, NULL, 0);

    // 3.读数据
    printf("%s\n", (char *)ptr);
    
    printf("按任意键继续\n");
    getchar();

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

    // 5.删除共享内存
    shmctl(shmid, IPC_RMID, NULL);
    return 0;
}

// gyw_read_shm.c
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <string.h>

int main() {    

    // 1.获取一个共享内存
    key_t key1= ftok("/root/LinuxLearn/lesson26",'a');
    int shmid = shmget(key1,4096, IPC_CREAT);
    printf("shmid : %d\n", shmid);
    // 2.和当前进程进行关联
    void*ptr=shmat(shmid,NULL,0);

    // 3.读数据
    printf("%s\n",(char*) ptr);
    printf("按任意键继续\n");
    getchar();
    
    // 4.解除关联
    shmdt(ptr);

    // 5.删除共享内存
    shmctl(shmid, IPC_RMID, NULL);

    return 0;
}

04 / 共享内存操作命令

  • 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标识的信号

05 / 共享内存相关提问

问题1:操作系统如何知道一块共享内存被多少个进程关联?
    - 共享内存维护了一个结构体struct shmid_ds 这个结构体中有一个成员 shm_nattch
    - shm_nattach 记录了关联的进程个数

问题2:可不可以对共享内存进行多次删除 shmctl
    - 可以的
    - 因为shmctl 标记删除共享内存,不是直接删除
    - 什么时候真正删除呢?
        当和共享内存关联的进程数为0的时候,就真正被删除
    - 当共享内存的key为0的时候,表示共享内存被标记删除了
        如果一个进程和共享内存取消关联,那么这个进程就不能继续操作这个共享内存。也不能再次进行关联。

问题3:共享内存和内存映射的区别
    1.共享内存可以直接创建,内存映射需要磁盘文件(匿名映射除外)
    2.共享内存效果更高
    3.内存 
        所有的进程操作的是同一块共享内存。
        内存映射,每个进程在自己的虚拟地址空间中有一个独立的内存。
    4.数据安全
        - 进程突然退出
            共享内存还存在
            内存映射区消失
        - 运行进程的电脑死机,宕机了
            数据存在在共享内存中,没有了
            内存映射区的数据 ,由于磁盘文件中的数据还在,所以内存映射区的数据还存在。
    5.生命周期
        - 内存映射区:进程退出,内存映射区销毁
        - 共享内存:进程退出,共享内存还在,手动标记删除(所有的关联的进程数为0),或者关机
            如果一个进程退出,会自动和共享内存进行取消关联。

2.12 守护进程

01 / 终端

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

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

  • 在控制终端输入一些特殊的控制键可以给前台进程发信号,例如Ctrl + C 会产生SIGINT 信号,Ctrl + \ 会产生SIGQUIT 信号。(后台进程也不能被控制终端控制,Ctrl + C终止不了他)

    tty				# 查看这个终端设备	如:/dev/pts/ 1
    echo $$			# 查看这个终端进程的id  如:70597

02 / 进程组

  • 进程组和会话在进程之间形成了一种两级层次关系:进程组是一组相关进程的集合会话是一组相关进程组的集合。进程组和会话是为支持shell 作业控制而定义的抽象概念,用户通过shell 能够交互式地在前台或后台运行命令。
  • 进程组由一个或多个共享同一进程组标识符(PGID)的进程组成。一个进程组拥有一个进程组首进程,该进程是创建该组的进程,其进程ID 为该进程组的ID,新进程会继承其父进程所属的进程组ID。
  • 进程组拥有一个生命周期,其开始时间为首进程创建组的时刻结束时间为最后一个成员进程退出组的时刻。一个进程可能会因为终止而退出进程组,也可能会因为加入了另外一个进程组而退出进程组。进程组首进程无需是最后一个离开进程组的成员

03 / 会话

  • 会话是一组进程组的集合。会话首进程是创建该新会话的进程,其进程ID 会成为会话ID。新进程会继承其父进程的会话ID。
  • 一个会话中的所有进程共享单个控制终端。控制终端会在会话首进程首次打开一个终端设备时被建立。一个终端最多可能会成为一个会话的控制终端。
  • 在任一时刻,会话中的其中一个进程组会成为终端的前台进程组,其他进程组会成为后台进程组。只有前台进程组中的进程才能从控制终端中读取输入。当用户在控制终端中输入终端字符生成信号后,该信号会被发送到前台进程组中的所有成员。
  • 当控制终端的连接建立起来之后,会话首进程会成为该终端的控制进程

04 / 进程组、会话、控制终端之间的关系

find / 2 > /dev/null| wC -l &
sort < longlist | uniq -C

image-20230207230127827

05 / 进程组、会话操作函数

pid_t getpgrp(void);
pid_t getpgid(pid_t pid);
int setpgid(pid_t pid, pid_t pgid);
pid_t getsid(pid_t pid);
pid_t setsid(void);

06 / 守护进程

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

07 / 守护进程的创建步骤

  • 执行一个fork(),之后父进程退出,子进程继续执行。(确保子进程不会是组长。如果不退出可能会在进程结束在终端出现$符)
  • 子进程调用setsid() 开启一个新会话。(会话首进程会成为该终端的控制进程)
  • 清除进程的umask 以确保当守护进程创建文件和目录时拥有所需的权限。
  • 修改进程的当前工作目录,通常会改为根目录(/)。
  • 关闭守护进程从其父进程继承而来的所有打开着的文件描述符。
  • 在关闭了文件描述符0、1、2之后,守护进程通常会打开/dev/null 并使用dup2()使所有这些描述符指向这个设备。
  • 核心业务逻辑
/*
    写一个守护进程,每隔2s获取一下系统时间,将这个时间写入到磁盘文件中。
*/
#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/time.h>
#include <signal.h>
#include <time.h>
#include <stdlib.h>
#include <string.h>

void work(int num) {
    // 捕捉到信号之后,获取系统时间,写入磁盘文件
    time_t tm = time(NULL);
    struct tm * loc = localtime(&tm);
    // char buf[1024];

    // sprintf(buf, "%d-%d-%d %d:%d:%d\n",loc->tm_year,loc->tm_mon
    // ,loc->tm_mday, loc->tm_hour, loc->tm_min, loc->tm_sec);

    // printf("%s\n", buf);

    char * str = asctime(loc);
    int fd = open("time.txt", O_RDWR | O_CREAT | O_APPEND, 0664);
    write(fd ,str, strlen(str));
    close(fd);
}

int main() {

    // 1.创建子进程,退出父进程
    pid_t pid = fork();
    if(pid > 0) { exit(0); }

    // 2.将子进程重新创建一个会话,
    setsid();

    // 3.设置掩码
    umask(022);

    // 4.更改工作目录
    chdir("/home/nowcoder/");

    // 5. 关闭、重定向文件描述符
    int fd = open("/dev/null", O_RDWR);
    dup2(fd, STDIN_FILENO);
    dup2(fd, STDOUT_FILENO);
    dup2(fd, STDERR_FILENO);

    // 6.业务逻辑

    // 捕捉定时信号
    struct sigaction act;
    act.sa_flags = 0;
    act.sa_handler = work;
    sigemptyset(&act.sa_mask);
    sigaction(SIGALRM, &act, NULL);

    struct itimerval val;
    val.it_value.tv_sec = 2;
    val.it_value.tv_usec = 0;
    val.it_interval.tv_sec = 2;
    val.it_interval.tv_usec = 0;

    // 创建定时器
    setitimer(ITIMER_REAL, &val, NULL);

    // 不让进程结束
    while(1) {
        sleep(10);
    }
    return 0;
}

第三章 Linux多线程开发

3.1 线程概念

01 / 线程概述

  • 与进程(process)类似,线程(thread)是允许应用程序并发执行多个任务的一种机制一个进程可以包含多个线程。同一个程序中的所有线程均会独立执行相同程序,且共享同一份全局内存区域,其中包括初始化数据段、未初始化数据段,以及堆内存段。(传统意义上的UNIX 进程只是多线程程序的一个特例,该进程只包含一个线程)
  • 进程是CPU 分配资源的最小单位,线程是操作系统调度执行的最小单位
  • 线程是轻量级的进程(LWP:Light Weight Process),在Linux 环境下线程的本质仍是进程。
  • 查看指定进程的LWP 号:ps –Lf pid

02 / 线程和进程区别

  • 进程间的信息难以共享。由于除去只读代码段外,父子进程并未共享内存,因此必须采用一些进程间通信方式,在进程间进行信息交换。
  • 调用fork() 来创建进程的代价相对较高,即便利用写时复制技术,仍然需要复制诸如内存页表和文件描述符表之类的多种进程属性,这意味着fork()调用在时间上的开销依然不菲。
  • 线程之间能够方便、快速地共享信息。只需将数据复制到共享(全局或堆)变量中即可。
  • 创建线程比创建进程通常要快10 倍甚至更多。线程间是共享虚拟地址空间的,无需采用写时复制来复制内存,也无需复制页表。

image-20230211095016665

03 / 线程之间共享和非共享资源

image-20230211095143485

04 / NPTL

  • 当Linux 最初开发时,在内核中并不能真正支持线程。但是它的确可以通过clone()系统调用将进程作为可调度的实体。这个调用创建了调用进程(calling process)的一个拷贝,这个拷贝与调用进程共享相同的地址空间。LinuxThreads 项目使用这个调用来完成在用户空间模拟对线程的支持。不幸的是,这种方法有一些缺点,尤其是在信号处理、调度和进程间同步等方面都存在问题。另外,这个线程模型也不符合POSIX 的要求。
  • 要改进LinuxThreads,需要内核的支持,并且重写线程库。有两个相互竞争的项目开始来满足这些要求。一个包括IBM 的开发人员的团队开展了NGPT(Next-GenerationPOSIX Threads)项目。同时,Red Hat 的一些开发人员开展了NPTL 项目。NGPT在2003 年中期被放弃了,把这个领域完全留给了NPTL。
  • NPTL,或称为Native POSIX Thread Library,是Linux 线程的一个新实现,它克服了LinuxThreads 的缺点,同时也符合POSIX 的需求。与LinuxThreads 相比,它在性能和稳定性方面都提供了重大的改进。
  • 查看当前pthread 库版本:getconf GNU_LIBPTHREAD_VERSION

05 / 线程操作

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void *(*start_routine) (void *), void *arg);	// 创建一个子线程
pthread_t pthread_self(void);						//获取当前的线程的线程ID
int pthread_equal(pthread_t t1, pthread_t t2);  	  //比较两个线程ID是否相等
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);				//取消线程(让线程终止)
  • 一般情况下,main函数所在的线程我们称之为主线程(main线程),其余创建的线程 称之为子线程。
  • 程序中默认只有一个进程,fork()函数调用,2个进程
  • 程序中默认只有一个线程,pthread_create()函数调用,2个线程。
/* 
#include <pthread.h>
    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);
*/
#include <stdio.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>

void * callback(void * arg) {
    printf("child thread...\n");
    printf("arg value: %d\n", *(int *)arg);
    return NULL;
}

int main() {
    pthread_t tid;
    int num = 10;

    // 创建一个子线程
    int ret = pthread_create(&tid, NULL, callback, (void *)&num);
    if(ret != 0) {
        char * errstr = strerror(ret);
        printf("error : %s\n", errstr);
    } 

    for(int i = 0; i < 5; i++) {
        printf("%d\n", i);
    }
    sleep(1);
    return 0;   // exit(0);
}
/* 
#include <pthread.h>
    void pthread_exit(void *retval);
        功能:终止一个线程,在哪个线程中调用,就表示终止哪个线程
        参数:
            retval:需要传递一个指针,作为一个返回值,可以在pthread_join()中获取到。

    pthread_t pthread_self(void);
        功能:获取当前的线程的线程ID

    int pthread_equal(pthread_t t1, pthread_t t2);
        功能:比较两个线程ID是否相等
        不同的操作系统,pthread_t类型的实现不一样,有的是无符号的长整型,有的是使用结构体去实现的。
*/
#include <stdio.h>
#include <pthread.h>
#include <string.h>

void * callback(void * arg) {
    printf("child thread id : %ld\n", pthread_self());
    return NULL;    // pthread_exit(NULL);
} 

int main() {
    // 创建一个子线程
    pthread_t tid;
    int ret = pthread_create(&tid, NULL, callback, NULL);//正常返回0
    if(ret != 0) {  
        char * errstr = strerror(ret);
        printf("error : %s\n", errstr);
    }

    // 主线程
    for(int i = 0; i < 5; i++) {
        printf("%d\n", i);
    }
    printf("tid : %ld, main thread id : %ld\n", tid ,pthread_self());

    // 让主线程退出,当主线程退出时,不会影响其他正常运行的线程。
    pthread_exit(NULL);
    printf("main thread exit\n");
    return 0;   // exit(0);
}
/*
#include <pthread.h>
    int pthread_join(pthread_t thread, void **retval);
        - 功能:和一个已经终止的线程进行连接, 回收子线程的资源
                这个函数是阻塞函数,调用一次只能回收一个子线程, 一般在主线程中使用
        - 参数:
            - thread:需要回收的子线程的ID
            - retval: 接收子线程退出时的返回值
        - 返回值:
            0 : 成功
            非0 : 失败,返回的错误号
*/
#include <stdio.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>

int value = 10;

void * callback(void * arg) {
    printf("child thread id : %ld\n", pthread_self());
    // sleep(3);
    // return NULL; 
    // int value = 10; // 局部变量    不要返回局部变量
    pthread_exit((void *)&value);   // return (void *)&value;
} 

int main() {
    // 创建一个子线程
    pthread_t tid;
    int ret = pthread_create(&tid, NULL, callback, NULL);
    if(ret != 0) {
        char * errstr = strerror(ret);
        printf("error : %s\n", errstr);
    }

    // 主线程
    for(int i = 0; i < 5; i++) {
        printf("%d\n", i);
    }

    printf("tid : %ld, main thread id : %ld\n", tid ,pthread_self());

    // 主线程调用pthread_join()回收子线程的资源
    int * thread_retval;
	ret = pthread_join(tid, (void **)&thread_retval); //(void **)强转为这个二级指针类型
    if(ret != 0) {
        char * errstr = strerror(ret);
        printf("error : %s\n", errstr);
    }
	*thread_retval=30;  //(void *)/(void **)&thread_retval都可以改变他的值

    printf("exit data : %d\n", *thread_retval);
    printf("回收子线程资源成功!\n");

    // 让主线程退出,当主线程退出时,不会影响其他正常运行的线程。
    pthread_exit(NULL);
    return 0; 
}
/*
#include <pthread.h>
    int pthread_detach(pthread_t thread);
        - 功能:分离一个线程。被分离的线程在终止的时候,会自动释放资源返回给系统。
          1.不能多次分离,会产生不可预料的行为。
          2.不能去连接一个已经分离的线程,会报错。
        - 参数:需要分离的线程的ID
        - 返回值:
            成功:0
            失败:返回错误号
*/
#include <stdio.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>

void * callback(void * arg) {
    printf("chid thread id : %ld\n", pthread_self());
    return NULL;
}

int main() {
    // 创建一个子线程
    pthread_t tid;
    int ret = pthread_create(&tid, NULL, callback, NULL);
    if(ret != 0) {
        char * errstr = strerror(ret);
        printf("error1 : %s\n", errstr);
    }

    // 输出主线程和子线程的id
    printf("tid : %ld, main thread id : %ld\n", tid, pthread_self());

    // 设置子线程分离,子线程分离后,子线程结束时对应的资源就不需要主线程释放
    ret = pthread_detach(tid);
    if(ret != 0) {
        char * errstr = strerror(ret);
        printf("error2 : %s\n", errstr);
    }

    // 设置分离后,对分离的子线程进行连接 pthread_join()
    // ret = pthread_join(tid, NULL);
    // if(ret != 0) {
    //     char * errstr = strerror(ret);
    //     printf("error3 : %s\n", errstr);
    // }

    pthread_exit(NULL);
    return 0;
}
/*
    #include <pthread.h>
    int pthread_cancel(pthread_t thread);
        - 功能:取消线程(让线程终止)
            取消某个线程,可以终止某个线程的运行,
            但是并不是立马终止,而是当子线程执行到一个取消点,线程才会终止。
            取消点:系统规定好的一些系统调用,我们可以粗略的理解为从用户区到内核区的切换,这个位置称之为取消点。
*/

#include <stdio.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>

void * callback(void * arg) {
    printf("chid thread id : %ld\n", pthread_self());
    for(int i = 0; i < 5; i++) {
        printf("child : %d\n", i);
    }
    return NULL;
}

int main() {
    // 创建一个子线程
    pthread_t tid;

    int ret = pthread_create(&tid, NULL, callback, NULL);
    if(ret != 0) {
        char * errstr = strerror(ret);
        printf("error1 : %s\n", errstr);
    }

    // 取消线程
    pthread_cancel(tid);

    for(int i = 0; i < 5; i++) {
        printf("%d\n", i);
    }

    // 输出主线程和子线程的id
    printf("tid : %ld, main thread id : %ld\n", tid, pthread_self());
    pthread_exit(NULL);
    return 0;
}

06 / 线程属性

线程属性类型 pthread_attr_t
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);			//设置线程分离的状态属性
#include <stdio.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>

void * callback(void * arg) {
    printf("chid thread id : %ld\n", pthread_self());
    return NULL;
}

int main() {
    // 创建一个线程属性变量
    pthread_attr_t attr;
    // 初始化属性变量
    pthread_attr_init(&attr);

    // 设置属性
    pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);

    // 创建一个子线程
    pthread_t tid;

    int ret = pthread_create(&tid, &attr, callback, NULL);
    if(ret != 0) {
        char * errstr = strerror(ret);
        printf("error1 : %s\n", errstr);
    }

    // 获取线程的栈的大小
    size_t size;
    pthread_attr_getstacksize(&attr, &size);
    printf("thread stack size : %ld\n", size);

    // 输出主线程和子线程的id
    printf("tid : %ld, main thread id : %ld\n", tid, pthread_self());

    // 释放线程属性资源
	pthread_attr_destroy(&attr);

    pthread_exit(NULL);	
    return 0;
}

3.2 线程同步

01 / 线程同步

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

// 全局变量,所有的线程都共享这一份资源。
int tickets = 100;

void * sellticket(void * arg) {    // 卖票
    while(tickets > 0) {
        usleep(6000);   //睡眠微秒
        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_detach(tid1);
    // pthread_detach(tid2);
    // pthread_detach(tid3);

    pthread_exit(NULL); // 退出主线程
    return 0;
}

02 / 互斥量

  • 为避免线程更新共享变量时出现问题,可以使用互斥量mutex 是mutual exclusion的缩写)来确保同时仅有一个线程可以访问某项共享资源。可以使用互斥量来保证对任意共享资源的原子访问。

  • 互斥量有两种状态:已锁定(locked)和未锁定(unlocked)。任何时候,至多只有一个线程可以锁定该互斥量。试图对已经锁定的某一互斥量再次加锁,将可能阻塞线程或者报错失败,具体取决于加锁时使用的方法。

  • 一旦线程锁定互斥量,随即成为该互斥量的所有者,只有所有者才能给互斥量解锁。一般情况下,对每一共享资源(可能由多个相关变量组成)会使用不同的互斥量,每一线程在访问同一资源时将采用如下协议:

    • 针对共享资源锁定互斥量
    • 访问共享资源
    • 对互斥量解锁
  • 如果多个线程试图执行这一块代码(一个临界区),事实上只有一个线程能够持有该互斥量(其他线程将遭到阻塞),即同时只有一个线程能够进入这段代码区域,如下图所示:

image-20230211102303486

03 / 互斥量相关操作函数

互斥量的类型pthread_mutex_t
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);	//初始化互斥量
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);		//解锁
/*
    互斥量的类型 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;(不能操作)

    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);
        - 解锁
*/
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

// 全局变量,所有的线程都共享这一份资源。
int tickets = 1000;

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

void * sellticket(void * arg) {

    // 卖票
    while(1) {
        // 加锁
        pthread_mutex_lock(&mutex);  //自旋锁(阻塞)

        if(tickets > 0) {
            //usleep(6000);
            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;
}

04 / 死锁

  • 有时,一个线程需要同时访问两个或更多不同的共享资源,而每个资源又都由不同的互斥量管理。当超过一个线程加锁同一组互斥量时,就有可能发生死锁。
  • 两个或两个以上的进程在执行过程中,因争夺共享资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁。
  • 死锁的几种场景:
    • 忘记释放锁
    • 重复加锁
    • 多线程多锁,抢占锁资源

image-20230211104444342

死锁的条件:1.互斥,2.拥有并等待,3.非抢占式,4.循环等待。满足以上条件才会死锁

05 / 读写锁

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

06 / 读写锁相关操作函数

读写锁的类型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);		//解锁
/*
    案例:8个线程操作同一个全局变量。
    3个线程不定时写这个全局变量,5个线程不定时的读这个全局变量
*/

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

// 创建一个共享数据
int num = 1;
// pthread_mutex_t mutex;
pthread_rwlock_t rwlock;

void * writeNum(void * arg) {
    while(1) {
        pthread_rwlock_wrlock(&rwlock);
        num++;
        printf("++write, tid : %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, tid : %ld, num : %d\n", pthread_self(), num);
        pthread_rwlock_unlock(&rwlock);
        usleep(100);
    }
    return NULL;
}

int main() {
   pthread_rwlock_init(&rwlock, NULL);

    // 创建3个写线程,5个读线程
    pthread_t wtids[3], rtids[5];
    for(int i = 0; i < 3; i++) {
        pthread_create(&wtids[i], NULL, writeNum, NULL);
    }

    for(int i = 0; i < 5; i++) {
        pthread_create(&rtids[i], NULL, readNum, NULL);
    }

    // 设置线程分离
    for(int i = 0; i < 3; i++) {
       pthread_detach(wtids[i]);
    }

    for(int i = 0; i < 5; i++) {
         pthread_detach(rtids[i]);
    }

    pthread_exit(NULL);
    pthread_rwlock_destroy(&rwlock); 
    return 0;
}

07 / 生产者消费者模型

image-20230211193105036

/*
    生产者消费者模型(粗略的版本)
*/
#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>

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

struct Node{        //作为容器
    int num;
    struct Node *next;
};

// 头结点
struct Node * head = NULL;

void * producer(void * arg) {  //不考虑到容器满的情况
    // 不断的创建新的节点,添加到链表中
    while(1) {
        pthread_mutex_lock(&mutex);
        struct Node * newNode = (struct Node *)malloc(sizeof(struct Node));//默认返回void*,我们强转 (struct Node *)
        newNode->next = head;		//头插法
        head = newNode;
        newNode->num = rand() % 1000;
        printf("add node, num : %d, tid : %ld\n", newNode->num, pthread_self());
        pthread_mutex_unlock(&mutex);
        usleep(100);
    }
    return NULL;
}

void * customer(void * arg) {
    while(1) {
        pthread_mutex_lock(&mutex);
        // 保存头结点的指针
        struct Node * tmp = head;

        // 判断是否有数据
        if(head != NULL) {
            // 有数据
            head = head->next;
            printf("del node, num : %d, tid : %ld\n", tmp->num, pthread_self());
            free(tmp);
            pthread_mutex_unlock(&mutex);
            usleep(100);
        } else {
            // 没有数据
            pthread_mutex_unlock(&mutex);
        }
    }
    return  NULL;
}

int main() {
    pthread_mutex_init(&mutex, NULL);

    // 创建5个生产者线程,和5个消费者线程
    pthread_t ptids[5], ctids[5];

    for(int i = 0; i < 5; i++) {
        pthread_create(&ptids[i], NULL, producer, NULL);
        pthread_create(&ctids[i], NULL, customer, NULL);
    }

    for(int i = 0; i < 5; i++) {
        pthread_detach(ptids[i]);
        pthread_detach(ctids[i]);
    }

    while(1) {    //避免执行完for循环就直接销毁锁
        sleep(10);
    }

    pthread_mutex_destroy(&mutex);
    pthread_exit(NULL);
    return 0;
}

08 / 条件变量

条件变量的类型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);//函数用于等待目标条件变量.该函数
//\调用时需要传入 mutex参数(加锁的互斥锁) ,函数执行时,先把调用线程放入条件变量的请求队列,然后将互斥锁mutex解锁,
//\当函数成功返回为0时,互斥锁会再次被锁上. 也就是说函数内部会有一次解锁和加锁操作.
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);
/*
    条件变量的类型 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);
 		- 调用下面两个函数时,调用线程必须锁定mutex,否则会产生未定义行为。
	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);
        - 唤醒所有的等待的线程
*/
#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>

// 创建一个互斥量
pthread_mutex_t mutex;
// 创建条件变量
pthread_cond_t cond;

struct Node{
    int num;
    struct Node *next;
};

// 头结点
struct Node * head = NULL;

void * producer(void * arg) {

    // 不断的创建新的节点,添加到链表中
    while(1) {
        pthread_mutex_lock(&mutex);
        struct Node * newNode = (struct Node *)malloc(sizeof(struct Node));
        newNode->next = head;
        head = newNode;
        newNode->num = rand() % 1000;
        printf("add node, num : %d, tid : %ld\n", newNode->num, pthread_self());
        
        // 只要生产了一个,就通知消费者消费
        pthread_cond_signal(&cond);

        pthread_mutex_unlock(&mutex);
        usleep(100);
    }

    return NULL;
}

void * customer(void * arg) {

    while(1) {
        pthread_mutex_lock(&mutex);
        // 保存头结点的指针
        struct Node * tmp = head;
        // 判断是否有数据
        if(head != NULL) {
            // 有数据
            head = head->next;
            printf("del node, num : %d, tid : %ld\n", tmp->num, pthread_self());
            free(tmp);
            pthread_mutex_unlock(&mutex);
            usleep(100);
        } else {
            // 没有数据,需要等待
        	// 当这个函数调用阻塞的时候,会对互斥锁进行解锁,当不阻塞的,继续向下执行,会重新加锁。
            pthread_cond_wait(&cond, &mutex);
            pthread_mutex_unlock(&mutex);
        }
    }
    return  NULL;
}

int main() {

    pthread_mutex_init(&mutex, NULL);
    pthread_cond_init(&cond, NULL);

    // 创建5个生产者线程,和5个消费者线程
    pthread_t ptids[5], ctids[5];

    for(int i = 0; i < 5; i++) {
        pthread_create(&ptids[i], NULL, producer, NULL);
        pthread_create(&ctids[i], NULL, customer, NULL);
    }

    for(int i = 0; i < 5; i++) {
        pthread_detach(ptids[i]);
        pthread_detach(ctids[i]);
    }

    while(1) {
        sleep(10);
    }

    pthread_mutex_destroy(&mutex);
    pthread_cond_destroy(&cond);

    pthread_exit(NULL);
    return 0;
}

09 / 信号量

信号量的类型sem_t
int sem_init(sem_t *sem, int pshared, unsigned int value);
int sem_destroy(sem_t *sem);
int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);
int sem_post(sem_t *sem);
int sem_getvalue(sem_t *sem, int *sval);
/*
    信号量的类型 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);
        - 对信号量加锁,调用一次对信号量的值-1,如果值为0,就阻塞
        - 功能:它的作用是从信号量的值减去一个“1”,但它永远会先等待该信号量为一个非零值(大于0)才开始做减法。
            (如果对一个值为0的信号量调用sem_wait(),这个函数就会等待,直到有其它线程增加了信号量这个值使它不再是0为止,
            再进行减1操作。)
        - 返回值:操作成功返回0,失败则返回-1且置errno
        - 参数sem:指向信号量结构的一个指针


    int sem_trywait(sem_t *sem);
		- 功能:sem_trywait()为sem_wait()的非阻塞版,不进行等待
		- 返回值:如果信号量计数大于0,则信号量立即减1并返回0,否则立即返回-1,errno置为EAGAIN
		- 参数sem:指向信号量结构的一个指针

    int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);
    int sem_post(sem_t *sem);
        - 对信号量解锁,调用一次对信号量的值+1

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

    sem_t psem;
    sem_t csem;
    init(psem, 0, 8);
    init(csem, 0, 0);

    producer() {
        sem_wait(&psem);  每调用一次  psem(8)这个value就减1,减到0就阻塞在这
        sem_post(&csem)   每调用一次  csem这个value就加1
    }

    customer() {
        sem_wait(&csem);  一开始就是0,就是一开始就要等待,csem这个value不为0就可以消费
        sem_post(&psem)     每调用一次  psem(8)这个value就加1
    }

*/

#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>
#include <semaphore.h>

// 创建一个互斥量
pthread_mutex_t mutex;
// 创建两个信号量
sem_t psem;
sem_t csem;

struct Node{
    int num;
    struct Node *next;
};

// 头结点
struct Node * head = NULL;

void * producer(void * arg) {

    // 不断的创建新的节点,添加到链表中
    while(1) {
        sem_wait(&psem);		//- 对信号量加锁
        pthread_mutex_lock(&mutex);
        struct Node * newNode = (struct Node *)malloc(sizeof(struct Node));
        newNode->next = head;
        head = newNode;
        newNode->num = rand() % 1000;
        printf("add node, num : %d, tid : %ld\n", newNode->num, pthread_self());
        pthread_mutex_unlock(&mutex);
        sem_post(&csem);
    }
    return NULL;
}

void * customer(void * arg) {

    while(1) {
        sem_wait(&csem);
        pthread_mutex_lock(&mutex);
        // 保存头结点的指针
        struct Node * tmp = head;
        head = head->next;
        printf("del node, num : %d, tid : %ld\n", tmp->num, pthread_self());
        free(tmp);
        pthread_mutex_unlock(&mutex);
        sem_post(&psem);
    }
    return  NULL;
}

int main() {

    pthread_mutex_init(&mutex, NULL);
    sem_init(&psem, 0, 8);
    sem_init(&csem, 0, 0);

    // 创建5个生产者线程,和5个消费者线程
    pthread_t ptids[5], ctids[5];

    for(int i = 0; i < 5; i++) {
        pthread_create(&ptids[i], NULL, producer, NULL);
        pthread_create(&ctids[i], NULL, customer, NULL);
    }

    for(int i = 0; i < 5; i++) {
        pthread_detach(ptids[i]);
        pthread_detach(ctids[i]);
    }

    while(1) {
        sleep(10);
    }

    pthread_mutex_destroy(&mutex);
    pthread_exit(NULL);
    return 0;
}

第四章 Linux网络编程

4.1 网络原理介绍

01 / 网络结构模式

C/S结构

简介
服务器 - 客户机,即 Client - Server(C/S)结构。C/S 结构通常采取两层结构。服务器负责数据的管理,客户机负责完成与用户的交互任务。客户机是因特网上访问别人信息的机器,服务器则是提供信息供人访问的计算机。

客户机通过局域网与服务器相连,接受用户的请求,并通过网络向服务器提出请求,对数据库进行操作。服务器接受客户机的请求,将数据提交给客户机,客户机将数据进行计算并将结果呈现给用户服务器还要提供完善安全保护及对数据完整性的处理等操作,并允许多个客户机同时访问服务器,这就对服务器的硬件处理数据能力提出了很高的要求。

在C/S结构中,应用程序分为两部分:服务器部分和客户机部分。服务器部分是多个用户共享的信息与功能,执行后台服务,如控制共享数据库的操作等;客户机部分为用户所专有,负责执行前台功能,在出错提示、在线帮助等方面都有强大的功能,并且可以在子程序间自由切换。

优点

  1. 能充分发挥客户端 PC 的处理能力,很多工作可以在客户端处理后再提交给服务器,所以 C/S 结构客户端响应速度快
  2. 操作界面漂亮、形式多样,可以充分满足客户自身的个性化要求;
  3. C/S 结构的管理信息系统具有较强的事务处理能力,能实现复杂的业务流程
  4. 安全性较高,C/S 一般面向相对固定的用户群,程序更加注重流程,它可以对权限进行多层次校验,提供了更安全的存取模式,对信息安全的控制能力很强,一般高度机密的信息系统采用 C/S 结构适宜。

缺点

  1. 客户端需要安装专用的客户端软件。首先涉及到安装的工作量,其次任何一台电脑出问题,如病毒、硬件损坏,都需要进行安装或维护。系统软件升级时,每一台客户机需要重新安装,其维护和升级成本非常高
  2. 对客户端的操作系统一般也会有限制,不能够跨平台

B/S结构

简介
B/S 结构(Browser/Server,浏览器/服务器模式),是 WEB 兴起后的一种网络结构模式,WEB浏览器是客户端最主要的应用软件。这种模式统一了客户端,将系统功能实现的核心部分集中到服务器上,简化了系统的开发、维护和使用。客户机上只要安装一个浏览器,如 Firefox 或 InternetExplorer,服务器安装 SQL Server、Oracle、MySQL 等数据库。浏览器通过 Web Server 同数据库进行数据交互。

优点
B/S 架构最大的优点是总体拥有成本低、维护方便、 分布性强、开发简单,可以不用安装任何专门的软件就能实现在任何地方进行操作,客户端零维护,系统的扩展非常容易,只要有一台能上网的电脑就能使用。

缺点

  1. 通信开销大、系统和数据的安全性较难保障;
  2. 个性特点明显降低,无法实现具有个性化的功能要求;
  3. 协议一般是固定的:http/https
  4. 客户端服务器端的交互是请求-响应模式,通常动态刷新页面,响应速度明显降低。

02 / MAC 地址

网卡是一块被设计用来允许计算机在计算机网络上进行通讯的计算机硬件,又称为网络适配器或网络接口卡NIC。其拥有 MAC 地址,属于 OSI 模型的第 2 层,它使得用户可以通过电缆或无线相互连接。每一个网卡都有一个被称为 MAC 地址的独一无二的 48 位串行号。网卡的主要功能:1.数据的封装与解封装、2.链路管理、3.数据编码与译码

image-20230213093758560

MAC 地址(Media Access Control Address),直译为媒体存取控制位址,也称为局域网地址以太网地址物理地址或硬件地址,它是一个用来确认网络设备位置的位址,由网络设备制造商生产时烧录在网卡中。在 OSI 模型中,第三层网络层负责 IP 地址,第二层数据链路层则负责 MAC位址 。MAC 地址用于在网络中唯一标识一个网卡,一台设备若有一或多个网卡,则每个网卡都需要并会有一个唯一的 MAC 地址。

MAC 地址的长度为 48 位(6个字节),通常表示为 12 个 16 进制数,如:00-16-EA-AE-3C-40 就是一个MAC 地址,其中前 3 个字节,16 进制数 00-16-EA 代表网络硬件制造商的编号,它由IEEE(电气与电子工程师协会)分配,而后 3 个字节,16进制数 AE-3C-40 代表该制造商所制造的某个网络产品(如网卡)的系列号。只要不更改自己的 MAC 地址,MAC 地址在世界是唯一的。形象地说,MAC 地址就如同身份证上的身份证号码,具有唯一性

03 / IP 地址

简介
IP 协议是为计算机网络相互连接进行通信而设计的协议。在因特网中,它是能使连接到网上的所有计算机网络实现相互通信的一套规则,规定了计算机在因特网上进行通信时应当遵守的规则。任何厂家生产的计算机系统,只要遵守 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地址(100.4.5.6),实际上是 32 位二进制数(01100100.00000100.00000101.00000110)。

IP 地址编址方式
最初设计互联网络时,为了便于寻址以及层次化构造网络,每个 IP 地址包括两个标识码(ID),即网络ID 和主机 ID。同一个物理网络上的所有主机都使用同一个网络 ID,网络上的一个主机(包括网络上工作站,服务器和路由器等)有一个主机 ID 与其对应。Internet 委员会定义了 5 种 IP 地址类型以适合不同容量的网络,即 A 类~ E 类。
其中 A、B、C 3类(如下表格)由 InternetNIC 在全球范围内统一分配,D、E 类为特殊地址。

image-20230213095617833

A类IP地址
一个 A 类 IP 地址是指, 在 IP 地址的四段号码中,第一段号码为网络号码,剩下的三段号码为本地计算机的号码。如果用二进制表示 IP 地址的话,A 类 IP 地址就由 1 字节的网络地址和 3 字节主机地址组成,网络地址的最高位必须是“0”。A 类 IP 地址中网络的标识长度为 8 位,主机标识的长度为 24 位,A类网络地址数量较少,有 126 个网络,每个网络可以容纳主机数达 1600 多万台。

A 类 IP 地址 地址范围 1.0.0.1 - 126.255.255.254(二进制表示为:00000001 00000000 00000000 00000001 - 01111111 11111111 11111111 11111110)。最后一个是广播地址。
A 类 IP 地址的子网掩码为 255.0.0.0,每个网络支持的最大主机数为 256 的 3 次方 - 2 = 16777214 台

B类IP地址
一个 B 类 IP 地址是指,在 IP 地址的四段号码中,前两段号码为网络号码。如果用二进制表示 IP 地址的话,B 类 IP 地址就由 2 字节的网络地址和 2 字节主机地址组成,网络地址的最高位必须是“10”。B 类 IP地址中网络的标识长度为 16 位,主机标识的长度为 16 位,B 类网络地址适用于中等规模的网络,有16384 个网络,每个网络所能容纳的计算机数为 6 万多台。

B 类 IP 地址地址范围 128.0.0.1 - 191.255.255.254 (二进制表示为:10000000 00000000 00000000 00000001 - 10111111 11111111 11111111 11111110)。 最后一个是广播地址。
B 类 IP 地址的子网掩码为 255.255.0.0,每个网络支持的最大主机数为 256 的 2 次方 - 2 = 65534 台

C类IP地址
一个 C 类 IP 地址是指,在 IP 地址的四段号码中,前三段号码为网络号码,剩下的一段号码为本地计算机的号码。如果用二进制表示 IP 地址的话,C 类 IP 地址就由 3 字节的网络地址和 1 字节主机地址组成,网络地址的最高位必须是“110”。C 类 IP 地址中网络的标识长度为 24 位,主机标识的长度为 8 位,
C 类网络地址数量较多,有 209 万余个网络。适用于小规模的局域网络,每个网络最多只能包含254台计算机。
C 类 IP 地址范围 192.0.0.1-223.255.255.254 (二进制表示为: 11000000 00000000 00000000 00000001 - 11011111 11111111 11111111 11111110)。
C类IP地址的子网掩码为 255.255.255.0,每个网络支持的最大主机数为 256 - 2 = 254 台

D类IP地址
D 类 IP 地址在历史上被叫做多播地址(multicast address),即组播地址。在以太网中,多播地址命名了一组应该在这个网络中应用接收到一个分组的站点。多播地址的最高位必须是 “1110”,范围从224.0.0.0 - 239.255.255.255

特殊的网址
每一个字节都为 0 的地址( “0.0.0.0” )对应于当前主机;
IP 地址中的每一个字节都为 1 的 IP 地址( “255.255.255.255” )是当前子网的广播地址;
IP 地址中凡是以 “11110” 开头的 E 类 IP 地址都保留用于将来和实验使用。
IP地址中不能以十进制 “127” 作为开头,该类地址中数字 127.0.0.1 到 127.255.255.255 用于回路测试,如:127.0.0.1可以代表本机IP地址。

子网掩码
子网掩码(subnet mask)又叫网络掩码、地址掩码、子网络遮罩,它是一种用来指明一个 IP 地址的哪些位标识的是主机所在的子网,以及哪些位标识的是主机的位掩码。子网掩码不能单独存在,它必须结合 IP 地址一起使用。子网掩码只有一个作用,就是将某个 IP 地址划分成网络地址和主机地址两部分。

子网掩码是一个 32 位地址,用于屏蔽 IP 地址的一部分以区别网络标识和主机标识,并说明该 IP地址是在局域网上,还是在广域网上。

子网掩码是在 IPv4 地址资源紧缺的背景下为了解决 lP 地址分配而产生的虚拟 lP 技术,通过子网掩码将A、B、C 三类地址划分为若干子网,从而显著提高了 IP 地址的分配效率,有效解决了 IP 地址资源紧张的局面。另一方面,在企业内网中为了更好地管理网络,网管人员也利用子网掩码的作用,人为地将一个较大的企业内部网络划分为更多个小规模的子网,再利用三层交换机的路由功能实现子网互联,从而有效解决了网络广播风暴和网络病毒等诸多网络管理方面的问题。

在大多数的网络教科书中,一般都将子网掩码的作用描述为通过逻辑运算,将 IP 地址划分为网络标识(Net.ID) 和主机标识(Host.ID),只有网络标识相同的两台主机在无路由的情况下才能相互通信。

根据 RFC950 定义,子网掩码是一个 32 位的 2 进制数, 其对应网络地址的所有位都置为 1,对应于主机地址的所有位置都为 0。子网掩码告知路由器,地址的哪一部分是网络地址,哪一部分是主机地址,使路由器正确判断任意 IP 地址是否是本网段的,从而正确地进行路由。网络上,数据从一个地方传到另外一个地方,是依靠 IP 寻址。从逻辑上来讲,是两步的。第一步,从 IP 中找到所属的网络,好比是去找这个人是哪个小区的;第二步,再从 IP 中找到主机在这个网络中的位置,好比是在小区里面找到这个人。

子网掩码的设定必须遵循一定的规则。与二进制 IP 地址相同,子网掩码由 1 和 0 组成,且 1 和 0 分别连续。子网掩码的长度也是 32 ,左边是网络位,用二进制数字 “1” 表示,1 的数目等于网络位的长度;右边是主机位,用二进制数字 “0” 表示,0 的数目等于主机位的长度。这样做的目的是为了让掩码与 IP 地址做按位与运算时用 0 遮住原主机数,而不改变原网络段数字,而且很容易通过 0 的位数确定子
网的主机数( 2 的主机位数次方 - 2,因为主机号全为 1 时表示该网络广播地址,全为 0 时表示该网络的网络号,这是两个特殊地址)。通过子网掩码,才能表明一台主机所在的子网与其他子网的关系,使网络正常工作。

04 / 端口

简介
“端口” 是英文 port 的意译,可以认为是设备与外界通讯交流的出口。端口可分为虚拟端口和物理端口,其中虚拟端口指计算机内部或交换机路由器内的端口,不可见,是特指TCP/IP协议中的端口,是逻辑意义上的端口。例如计算机中的 80 端口、21 端口、23 端口等。物理端口又称为接口,是可见端口,计算机背板的 RJ45 网口,交换机路由器集线器等 RJ45 端口。电话使用 RJ11 插口也属于物理端口的范畴。**(相当于确认唯一的进程)**

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

端口类型

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

2.注册端口(Registered Ports)
端口号从 1024 到 49151,它们松散地绑定于一些服务,分配给用户进程或应用程序,这些进程主要是用户选择安装的一些应用程序,而不是已经分配好了公认端口的常用程序。这些端口在没有被服务器资源占用的时候,可以用用户端动态选用为源端口。

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

4.2 网络模型

01 / 七层参考模型

七层模型,亦称 OSI(Open System Interconnection)参考模型,即开放式系统互联。参考模型是国际标准化组织(ISO)制定的一个用于计算机或通信系统间互联的标准体系,一般称为 OSI 参考模型或七层模型。

它是一个七层的、抽象的模型体,不仅包括一系列抽象的术语或概念,也包括具体的协议。

image-20230213102605116

  1. 物理层:主要定义物理设备标准,如网线的接口类型、光纤的接口类型、各种传输介质的传输速率等。它的主要作用是传输比特流(就是由1、0转化为电流强弱来进行传输,到达目的地后再转化为1、0,也就是我们常说的数模转换与模数转换)。这一层的数据叫做比特
  2. 数据链路层建立逻辑连接、进行硬件地址寻址、差错校验等功能。定义了如何让格式化数据以帧为单位进行传输,以及如何让控制对物理介质的访问。将比特组合成字节进而组合成帧,用MAC地址访问介质。
  3. 网络层进行逻辑地址寻址,在位于不同地理位置的网络中的两个主机系统之间提供连接和路径选择。Internet的发展使得从世界各站点访问信息的用户数大大增加,而网络层正是管理这种连接的层。
  4. 传输层定义了一些传输数据的协议和端口号( WWW 端口 80 等),如:TCP(传输控制协议,传输效率低,可靠性强,用于传输可靠性要求高,数据量大的数据),UDP(用户数据报协议,与TCP 特性恰恰相反,用于传输可靠性要求不高,数据量小的数据,如 QQ 聊天数据就是通过这种方式传输的)。 主要是将从下层接收的数据进行分段和传输,到达目的地址后再进行重组。常常把这一层数据叫做
  5. 会话层:通过传输层(端口号:传输端口与接收端口)建立数据传输的通路。主要在你的系统之间发起会话或者接受会话请求。
  6. 表示层数据的表示、安全、压缩。主要是进行对接收的数据进行解释、加密与解密、压缩与解压缩等(也就是把计算机能够识别的东西转换成人能够能识别的东西(如图片、声音等)。
  7. 应用层:网络服务与最终用户的一个接口。这一层为用户的应用程序(例如电子邮件、文件传输和终端仿真)提供网络服务

02 / TCP/IP 四层模型

简介
现在 Internet(因特网)使用的主流协议族是 TCP/IP 协议族,它是一个分层、多协议的通信体系。TCP/IP协议族是一个四层协议系统,自底而上分别是数据链路层、网络层、传输层和应用层。每一层完成不同的功能,且通过若干协议来实现,上层协议使用下层协议提供的服务。

image-20230213102933846

TCP/IP 协议在一定程度上参考了 OSI 的体系结构。OSI 模型共有七层,从下到上分别是物理层、数据链路层、网络层、传输层、会话层、表示层和应用层。但是这显然是有些复杂的,所以在 TCP/IP 协议中,它们被简化为了四个层次。
(1)应用层、表示层、会话层三个层次提供的服务相差不是很大,所以在 TCP/IP 协议中,它们被合并为应用层一个层次。
(2)由于传输层和网络层在网络协议中的地位十分重要,所以在 TCP/IP 协议中它们被作为独立的两个层次。
(3)因为数据链路层和物理层的内容相差不多,所以在 TCP/IP 协议中它们被归并在网络接口层一个层次里。只有四层体系结构的 TCP/IP 协议,与有七层体系结构的 OSI 相比要简单了不少,也正是这样,TCP/IP 协议在实际的应用中效率更高,成本更低。

image-20230213103726147

四层介绍

  1. 应用层:应用层是 TCP/IP 协议的第一层,是直接为应用进程提供服务的。
    (1)对不同种类的应用程序它们会根据自己的需要来使用应用层的不同协议,邮件传输应用使用了 SMTP 协议、万维网应用使用了 HTTP 协议、远程登录服务应用使用了有 TELNET 协议。
    (2)应用层还能加密、解密、格式化数据。
    (3)应用层可以建立或解除与其他节点的联系,这样可以充分节省网络资源。
  2. 传输层:作为 TCP/IP 协议的第二层,运输层在整个 TCP/IP 协议中起到了中流砥柱的作用。且在运输层中, TCP 和 UDP 也同样起到了中流砥柱的作用。
  3. 网络层:网络层在 TCP/IP 协议中的位于第三层。在 TCP/IP 协议中网络层可以进行网络连接的建立和终止以及 IP 地址的寻找等功能。
  4. 网络接口层:在 TCP/IP 协议中,网络接口层位于第四层。由于网络接口层兼并了物理层和数据链路层所以,网络接口层既是传输数据的物理媒介,也可以为网络层提供一条准确无误的线路。

4.3 协议

简介
协议,网络协议的简称,网络协议是通信计算机双方必须共同遵从的一组约定。如怎么样建立连接、怎么样互相识别等。只有遵守这个约定,计算机之间才能相互通信交流。它的三要素是:语法、语义、时序。
为了使数据在网络上从源到达目的,网络通信的参与方必须遵循相同的规则,这套规则称为协议(protocol),它最终体现为在网络上传输的数据包的格式。
协议往往分成几个层次进行定义,分层定义是为了使某一层协议的改变不影响其他层次的协议。

常见协议
应用层常见的协议有: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 反向地址解析协议)。

01 / UDP协议

image-20230213104719559

  1. 源端口号:发送方端口号
  2. 目的端口号:接收方端口号
  3. 长度:UDP用户数据报的长度,最小值是8(仅有首部)
  4. 校验和:检测UDP用户数据报在传输中是否有错,有错就丢弃

02 / TCP协议

image-20230213104940241

  1. 源端口号:发送方端口号
  2. 目的端口号:接收方端口号
  3. 序列号:本报文段的数据的第一个字节的序号
  4. 确认序号:期望收到对方下一个报文段的第一个数据字节的序号
  5. 首部长度(数据偏移):TCP 报文段的数据起始处距离 TCP 报文段的起始处有多远,即首部长度。单位:32位,即以 4 字节为计算单位
  6. 保留:占 6 位,保留为今后使用,目前应置为 0
  7. 紧急 URG :此位置 1 ,表明紧急指针字段有效,它告诉系统此报文段中有紧急数据,应尽快传送
  8. 确认 ACK:仅当 ACK=1 时确认号字段才有效,TCP 规定,在连接建立后所有传达的报文段都必须把 ACK 置1
  9. 推送 PSH:当两个应用进程进行交互式的通信时,有时在一端的应用进程希望在键入一个命令后立即就能够收到对方的响应。在这种情况下,TCP 就可以使用推送(push)操作,这时,发送方TCP 把 PSH 置 1,并立即创建一个报文段发送出去,接收方收到 PSH = 1 的报文段,就尽快地(即“推送”向前)交付给接收应用进程,而不再等到整个缓存都填满后再向上交付
  10. 复位 RST:用于复位相应的 TCP 连接
  11. 同步 SYN:仅在三次握手建立 TCP 连接时有效。当 SYN = 1 而 ACK = 0 时,表明这是一个连接请求报文段,对方若同意建立连接,则应在相应的报文段中使用 SYN = 1 和 ACK = 1。因此,SYN 置1 就表示这是一个连接请求或连接接受报文
  12. 终止 FIN:用来释放一个连接。当 FIN = 1 时,表明此报文段的发送方的数据已经发送完毕,并要求释放运输连接
  13. 窗口:指发送本报文段的一方的接收窗口(而不是自己的发送窗口)
  14. 校验和:校验和字段检验的范围包括首部和数据两部分,在计算校验和时需要加上 12 字节的伪头部
  15. 紧急指针:仅在 URG = 1 时才有意义,它指出本报文段中的紧急数据的字节数(紧急数据结束后就是普通数据),即指出了紧急数据的末尾在报文中的位置,注意:即使窗口为零时也可发送紧急数据
  16. 选项:长度可变,最长可达 40 字节,当没有使用选项时,TCP 首部长度是 20 字节

03 / IP协议

image-20230213105821738

  1. 版本:IP 协议的版本。通信双方使用过的 IP 协议的版本必须一致,目前最广泛使用的 IP 协议版本号为 4(即IPv4)

  2. 首部长度:单位是 32 位(4 字节)

  3. 服务类型:一般不适用,取值为 0

  4. 总长度:指首部加上数据的总长度,单位为字节

  5. 标识(identification):IP 软件在存储器中维持一个计数器,每产生一个数据报,计数器就加 1,并将此值赋给标识字段

  6. 标志(flag):目前只有两位有意义。

    • 标志字段中的最低位记为 MF。MF = 1 即表示后面“还有分片”的数据报。MF = 0 表示这已是若干数据报片中的最后一个。
    • 标志字段中间的一位记为 DF,意思是“不能分片”,只有当 DF = 0 时才允许分片
  7. 片偏移:指出较长的分组在分片后,某片在源分组中的相对位置,也就是说,相对于用户数据段的起点,该片从何处开始。片偏移以 8 字节为偏移单位。

  8. 生存时间:TTL,表明是数据报在网络中的寿命,即为“跳数限制”,由发出数据报的源点设置这个字段。路由器在转发数据之前就把 TTL 值减一,当 TTL 值减为零时,就丢弃这个数据报。

  9. 协议:指出此数据报携带的数据时使用何种协议,以便使目的主机的 IP 层知道应将数据部分上交给哪个处理过程,常用的 ICMP(1),IGMP(2),TCP(6),UDP(17),IPv6(41)

  10. 首部校验和:只校验数据报的首部,不包括数据部分。

  11. 源地址:发送方 IP 地址

  12. 目的地址:接收方 IP 地址

04 / 以太网帧协议

image-20230213105947384

类型:0x800表示 IP、0x806表示 ARP、0x835表示 RARP

05 / ARP协议

根据IP地址查到mac地址,知道目的主机IP mac地址后才能发过去。(总长28个字节)

image-20230213110036165

  1. 硬件类型:1 表示 MAC 地址
  2. 协议类型:0x800 表示 IP 地址
  3. 硬件地址长度:6
  4. 协议地址长度:4
  5. 操作:1 表示 ARP 请求,2 表示 ARP 应答,3 表示 RARP 请求,4 表示 RARP 应答

9arp请求封装

06 / 封装

上层协议是如何使用下层协议提供的服务的呢?其实这是通过封装(encapsulation)实现的。应用程序数据在发送到物理网络上之前,将沿着协议栈从上往下依次传递。每层协议都将在上层数据的基础上加上自己的头部信息(有时还包括尾部信息),以实现该层的功能,这个过程就称为封装。

image-20230213110137231

07 / 分用

当帧到达目的主机时,将沿着协议栈自底向上依次传递。各层协议依次处理帧中本层负责的头部数据,以获取所需的信息,并最终将处理后的帧交给目标应用程序。这个过程称为分用(demultiplexing)。分用是依靠头部信息中的类型字段实现的。

image-20230213110222376

image-20230213110230493

9网络通信的过程

4.4 网络通信基础

01 / socket 介绍

所谓 socket(套接字),就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。从所处的地位来讲,套接字上联应用进程,下联网络协议栈,是应用程序通过网络协议进行通信的接口,是应用程序与网络协议根进行交互的接口。

socket 可以看成是两个网络应用程序进行通信时,各自通信连接中的端点,这是一个逻辑上的概念。它是网络环境中进程间通信的 API,也是可以被命名和寻址的通信端点,使用中的每一个套接字都有其类型和一个与之相连进程。通信时其中一个网络应用程序将要传输的一段信息写入它所在主机的 socket 中,该 socket 通过与网络接口卡(NIC)相连的传输介质将这段信息送到另外一台主机的 socket 中,使对方能够接收到这段信息。socket 是由 IP 地址和端口结合的,提供向应用层进程传送数据包的机制。

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

image-20230213190254495

套接字通信分两部分:

  • 服务器端:被动接受连接,一般不会主动发起连接
  • 客户端:主动向服务器发起连接
    socket是一套通信的接口,Linux 和 Windows 都有,但是有一些细微的差别。

02 / 字节序

简介
现代 CPU 的累加器一次都能装载(至少)4 字节(这里考虑 32 位机),即一个整数。那么这 4字节在内存中排列的顺序将影响它被累加器装载成的整数的值,这就是字节序问题。在各种计算机体系结构中,对于字节、字等的存储机制有所不同,因而引发了计算机通信领域中一个很重要的问题,即通信双方交流的信息单元(比特、字节、字、双字等等)应该以什么样的顺序进行传送。如果不达成一致的规则,通信双方将无法进行正确的编码/译码从而导致通信失败。

字节序,顾名思义字节的顺序,就是大于一个字节类型的数据在内存中的存放顺序(一个字节的数据当然就无需谈顺序的问题了)。

字节序分为大端字节序(Big-Endian) 和小端字节序(Little-Endian)。大端字节序是指一个整数的最高位字节(23 ~ 31 bit)存储在内存的低地址处低位字节(0 ~ 7 bit)存储在内存的高地址处小端字节序则是指整数的高位字节存储在内存的高地址处,而低位字节则存储在内存的低地址处

字节序举例

小端字节序
0x 01 02 03 04 - ff = 255 (16进制的数据 4个字节)
内存的方向 —–>
内存的低位 —–> 内存的高位
04 03 02 01

0x 11 22 33 44 12 34 56 78 (11为数据的高位字节,78为数据的低位字节)

image-20230213192840316

大端字节序
0x 01 02 03 04
内存的方向 —–>
内存的低位 —–> 内存的高位
01 02 03 04

0x 12 34 56 78 11 22 33 44(12为数据的高位字节,44为数据的低位字节)

image-20230213192944319

  1. 转换为大端字节序发送

  2. 接收方是大端就直接接收,接收方是小端就转化一下再接收,

/*  
    字节序:字节在内存中存储的顺序。
    小端字节序:数据的高位字节存储在内存的高位地址,低位字节存储在内存的低位地址
    大端字节序:数据的低位字节存储在内存的高位地址,高位字节存储在内存的低位地址
*/

// 通过代码检测当前主机的字节序
#include <stdio.h>
int main() {
    union {//共用体,也叫联合体,在一个“联合”内可以定义多种不同的数据类型,
     //一个被说明为该“联合”类型的变量中,允许装入该“联合”所定义的任何一种数据,
     //这些数据共享同一段内存,以达到节省空间的目的。union变量所占用的内存长度等于最长的成员的内存长度。
        short value;    // 2字节
        char bytes[sizeof(short)];  // char[2]
    } test;

    test.value = 0x0102;
    if((test.bytes[0] == 1) && (test.bytes[1] == 2)) {
        printf("大端字节序\n");
    } else if((test.bytes[0] == 2) && (test.bytes[1] == 1)) {
        printf("小端字节序\n");
    } else {
        printf("未知\n");
    }
    
    // union {
    //     int value;    // 4字节
    //     char bytes[sizeof(int)];  // char[4]
    // } test;

    // test.value = 0x01020304;  //16位的数据
    // if((test.bytes[0] == 1) && (test.bytes[1] == 2)) {
    //     printf("大端字节序\n");
    // } else if((test.bytes[0] == 4) && (test.bytes[1] == 3)) {
    //     printf("小端字节序\n");
    // } else {
    //     printf("未知\n");
    // }
    
    
    return 0;
}

03 / 字节序转换函数

当格式化的数据在两台使用不同字节序的主机之间直接传递时,接收端必然错误的解释之。解决问题的方法是:发送端总是把要发送的数据转换成大端字节序数据后再发送,而接收端知道对方传送过来的数据总是采用大端字节序,所以接收端可以根据自身采用的字节序决定是否对接收到的数据进行转换(小端机转换,大端机不转换)。(网络字节序都是大端,主机字节序都是小端

网络字节顺序是 TCP/IP 中规定好的一种数据表示格式,它与具体的 CPU 类型、操作系统等无关,从而可以保证数据在不同主机之间传输时能够被正确解释,网络字节顺序采用大端排序方式

BSD Socket提供了封装好的转换接口,方便程序员使用。包括从主机字节序到网络字节序的转换函数:htons、htonl从网络字节序到主机字节序的转换函数:ntohs、ntohl

h - host 主机,主机字节序
to - 转换成什么
n - network 网络字节序
s - short unsigned short
l - long unsigned int
#include <arpa/inet.h>
// 转换端口		16位,2个字节
uint16_t htons(uint16_t hostshort); // 主机字节序 -> 网络字节序
uint16_t ntohs(uint16_t netshort); // 主机字节序 <- 网络字节序
// 转IP		 32位,4个字节
uint32_t htonl(uint32_t hostlong); // 主机字节序 -> 网络字节序
uint32_t ntohl(uint32_t netlong); // 主机字节序 <- 网络字节序
/*
    网络通信时,需要将主机字节序转换成网络字节序(大端),
    另外一段获取到数据以后根据情况将网络字节序转换成主机字节序。

    // 转换端口		16位,2个字节
    uint16_t htons(uint16_t hostshort); // 主机字节序 -> 网络字节序
    uint16_t ntohs(uint16_t netshort); // 主机字节序 <- 网络字节序
    // 转IP		 32位,4个字节
    uint32_t htonl(uint32_t hostlong); // 主机字节序 -> 网络字节序
    uint32_t ntohl(uint32_t netlong); // 主机字节序 <- 网络字节序
*/

#include <stdio.h>
#include <arpa/inet.h>

int main() {
    // htons 转换端口
    unsigned short a= 0x0102;
    printf("主机小端a : %x\n",a);   //%x以十六进制的输出
    unsigned short b=htons(a);
    printf("主机转网络b : %x\n",b);   //%x以十六进制的输出
    printf("=======================\n");
    // ntohs
    unsigned short a1= 0x0102;
    printf("网络大端a1 : %x\n",a1);   //%x以十六进制的输出
    unsigned short b1=ntohs(a);
    printf("网络转主机b1 : %x\n",b1);   //%x以十六进制的输出
    printf("=======================\n");
    
    // htonl  转换IP
    char buf[4]={192,168,1,100};
    int num=*(int *)buf;
    // printf("num=%d\n",num);
    int sum=htonl(num);
    unsigned char* p=(char*)&sum;
    printf("网络端(大端):%d %d %d %d\n",*p,*(p+1),*(p+2),*(p+3));
    printf("=======================\n");

    // ntohl
    unsigned char buf1[4]={1,1,168,192};
    int num1=*(int*)buf1;
    int sum1=ntohl(num1);
    unsigned char *p1=(unsigned char*)&sum1;
    printf("主机端(小端):%d %d %d %d\n",*p1,*(p1+1),*(p1+2),*(p1+3));
    printf("=======================\n");

    return 0;
}

04 / socket 地址

// socket地址其实是一个结构体,封装端口号和IP等信息。后面的socket相关的api中需要使用到这个socket地址。
// 客户端 -> 服务器(IP, Port)

通用 socket 地址

#include <bits/socket.h>
struct sockaddr {
    sa_family_t sa_family;
    char sa_data[14];
};
typedef unsigned short int sa_family_t;

sa_family 成员是地址族类型(sa_family_t)的变量。地址族类型通常与协议族类型对应。常见的协议族(protocol family,也称 domain)和对应的地址族入下所示:

image-20230214190131983

宏 PF_* 和 AF_* 都定义在 bits/socket.h 头文件中,且后者与前者有完全相同的值,所以二者通常混用。

sa_data 成员用于存放 socket 地址值。但是,不同的协议族的地址值具有不同的含义和长度,如下所示:

image-20230214185851595

由上表可知,14 字节的 sa_data 根本无法容纳多数协议族的地址值。因此,Linux 定义了下面这个新的通用的 socket 地址结构体,这个结构体不仅提供了足够大的空间用于存放地址值,而且是内存对齐的。

#include <bits/socket.h>
struct sockaddr_storage
{
    sa_family_t sa_family;
    unsigned long int __ss_align;					//内存对齐
    char __ss_padding[ 128 - sizeof(__ss_align) ];	  //IP的存放
};
typedef unsigned short int sa_family_t;

专用 socket 地址
很多网络编程函数诞生早于 IPv4 协议,那时候都使用的是 struct sockaddr 结构体,为了向前兼容,现在sockaddr 退化成了(void *)的作用,传递一个地址给函数,至于这个函数是 sockaddr_in 还是sockaddr_in6,由地址族确定,然后函数内部再强制类型转化为所需的地址类型。

image-20230214190427327

UNIX 本地域协议族使用如下专用的 socket 地址结构体:

#include <sys/un.h>
struct sockaddr_un
{
    sa_family_t sin_family;
    char sun_path[108];
};

TCP/IP 协议族有 sockaddr_in 和 sockaddr_in6 两个专用的 socket 地址结构体,它们分别用于 IPv4 和IPv6:(我们基本都用这个)

#include <netinet/in.h>
struct sockaddr_in
{
    sa_family_t sin_family; /* __SOCKADDR_COMMON(sin_) */
    in_port_t sin_port; /* Port number. */
    struct in_addr sin_addr; /* Internet address. */
    /* Pad to size of `struct sockaddr'. */
    unsigned char sin_zero[sizeof (struct sockaddr) - __SOCKADDR_COMMON_SIZE - sizeof (in_port_t) - sizeof (struct in_addr)];
};
struct in_addr
{
    in_addr_t s_addr;
};
struct sockaddr_in6
{
    sa_family_t sin6_family;
    in_port_t sin6_port; /* Transport layer port # */
    uint32_t sin6_flowinfo; /* IPv6 flow information */
    struct in6_addr sin6_addr; /* IPv6 address */
    uint32_t sin6_scope_id; /* IPv6 scope-id */
};
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;
typedef uint16_t in_port_t;
typedef uint32_t in_addr_t;
#define __SOCKADDR_COMMON_SIZE (sizeof (unsigned short int))

所有专用 socket 地址(以及 sockaddr_storage)类型的变量在实际使用时都需要转化为通用 socket 地址类型 sockaddr(强制转化即可),因为所有 socket 编程接口使用的地址参数类型都是 sockaddr

05 / IP地址转换(字符串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 地址:

#include <arpa/inet.h>
// p:点分十进制的IP字符串,n:表示network,网络字节序的整数
int inet_pton(int af, const char *src, void *dst);
    af:地址族: AF_INET AF_INET6
    src:需要转换的点分十进制的IP字符串
    dst:转换后的结果保存在这个里面
// 将网络字节序的整数,转换成点分十进制的IP地址字符串
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
    af:地址族: AF_INET AF_INET6
    src: 要转换的ip的整数的地址
    dst: 转换成IP地址字符串保存的地方
    size:第三个参数的大小(数组的大小)
    返回值:返回转换后的数据的地址(字符串),和 dst 是一样的
/*
    #include <arpa/inet.h>
    // p:点分十进制的IP字符串,n:表示network,网络字节序的整数
    int inet_pton(int af, const char *src, void *dst);
        af:地址族: AF_INET  AF_INET6
        src:需要转换的点分十进制的IP字符串
        dst:转换后的结果保存在这个里面

    // 将网络字节序的整数,转换成点分十进制的IP地址字符串
    const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
        af:地址族: AF_INET  AF_INET6
        src: 要转换的ip的整数的地址
        dst: 转换成IP地址字符串保存的地方
        size:第三个参数的大小(数组的大小)
        返回值:返回转换后的数据的地址(字符串),和 dst 是一样的
*/

#include <stdio.h>
#include <arpa/inet.h>

int main() {

    // 创建一个ip字符串,点分十进制的IP地址字符串
    char buf[] = "192.168.1.4";
    unsigned int num =0;
    
    // 将点分十进制的IP字符串转换成网络字节序的整数
    inet_pton(AF_INET,buf,&num);
    unsigned char* p=(unsigned char*)&num;
    printf("%d %d %d %d\n", *p, *(p+1), *(p+2), *(p+3));

    // 将网络字节序的IP整数转换成点分十进制的IP字符串
    char ip[16]="";
    const char * str=inet_ntop(AF_INET,&num,ip,sizeof(ip));
    printf("str : %s\n", str);
    printf("ip : %s\n", str);
    printf("%d\n", ip == str);
    
    return 0;
}

06 / 地址转换最终版

#include <stdio.h>
// #include <sys/types.h>
// #include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char *argv[]) {

    const unsigned short port = 8989;//端口范围0-65536,unsigned short(2个字节)范围刚好0-65536不浪费空间
    const char ip[]={"192.168.73.101"};

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

    //2. 封装端口和IP
    struct sockaddr_in serverAddr;
    serverAddr.sin_family=AF_INET;  //ipv4
    inet_pton(AF_INET,ip,&serverAddr.sin_addr.s_addr);
    serverAddr.sin_port=htons(port);

    //3. 绑定
    int ret = bind(lfd,(struct sockaddr *)&serverAddr,sizeof(serverAddr));
    if(ret==-1){ perror("bind"); exit(-1); }
    
    //4. 监听
    ret=listen(lfd,8);
    if(ret==-1){ perror("listen"); exit(-1); }

    //5. 接收客户端连接
    struct sockaddr_in clientAddr;
    int len =sizeof(clientAddr);
    int refd=accept(lfd,(struct sockaddr *)&clientAddr,&len);
    if(refd==-1){ perror("accept"); exit(-1); }

    // 获取客户端信息
    char clientIP[16]={0};
    inet_ntop(AF_INET,&clientAddr.sin_addr.s_addr,clientIP,sizeof(clientIP));
    unsigned short clientPort=ntohs(clientAddr.sin_port);

    // 输出客户端的信息
    printf("客户端的IP是:%s   客户端的端口是:%d",clientIP,clientPort);

    //6. 接收客户端发来的数据
    char receiveInfo[1024]={0};
    while(1){
        int len=read(refd,&receiveInfo,sizeof(receiveInfo));
        if(len==-1){  perror("read"); exit(-1);
        }else if(len>0){
            printf("接收到的数据是:%s\n",receiveInfo);
        }else if(len==0){
            printf("客户端断开\n");
            break;
        }
    }

    // 写数据
    ret=write(refd,receiveInfo,strlen(receiveInfo));
    if(ret==-1){ perror("write"); exit(-1); }

    close(refd);
    close(lfd);
    return 0;
}

4.5 TCP通信流程

01 / TCP通信流程基础

// TCP 和 UDP -> 传输层的协议
UDP:用户数据报协议,面向无连接,可以单播,多播,广播, 面向数据报,不可靠
TCP:传输控制协议,面向连接的,可靠的,基于字节流,仅支持单播传输

UDP TCP
是否创建连接 无连接 面向连接
是否可靠 不可靠 可靠的
连接的对象个数 一对一、一对多、多对一、多对多 支持一对一
传输的方式 面向数据报 面向字节流
首部开销 8个字节 最少20个字节
适用场景 实时应用(视频会议,直播) 可靠性高的应用(文件传输)

image-20230215115400028

// 服务器端 (被动接受连接的角色)

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

// 客户端

  1. 创建一个用于通信的套接字(fd)
  2. 连接服务器,需要指定连接的服务器的 IP 和 端口
  3. 连接成功了,客户端可以直接和服务器通信
    • 接收数据
    • 发送数据
  4. 通信结束,断开连接

02 / 套接字函数

#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h> // 包含了这个头文件,上面两个就可以省略
int socket(int domain, int type, int protocol);
    - 功能:创建一个套接字
    - 参数:
        - domain: 协议族
             AF_INET : ipv4
             AF_INET6 : ipv6
			AF_UNIX, AF_LOCAL : 本地套接字通信(进程间通信)
    	- type: 通信过程中使用的协议类型
             SOCK_STREAM : 流式协议  流式协议默认使用 TCP
             SOCK_DGRAM : 报式协议  报式协议默认使用 UDP
    - protocol : 具体的一个协议。一般写0
    - 返回值:
        - 成功:返回文件描述符,操作的就是内核缓冲区。
        - 失败:-1
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); // socket命- 功能:绑定,将fd 和本地的IP + 端口进行绑定(服务端需要)
    - 参数:
        - sockfd : 通过socket函数得到的文件描述符
        - addr : 需要绑定的socket地址,这个地址封装了ip和端口号的信息
        - addrlen : 第二个参数结构体占的内存大小
int listen(int sockfd, int backlog); // /proc/sys/net/core/somaxconn	查看支持的最大连接数我的是128
    - 功能:监听这个socket上的连接
    - 参数:
        - sockfd : 通过socket()函数得到的文件描述符
        - backlog : 未连接的和已经连接的和的最大值, 5:(Unix苹果系统半连接队列和全连接队列的总和,Linux系统上全连接队列的总和)
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
    - 功能:接收客户端连接,默认是一个阻塞的函数,阻塞等待客户端连接 (从全连接队列中取出一个客户的连接请求)
    - 参数:
        - sockfd : 用于监听的文件描述符
        - addr : 传出参数,记录了连接成功后客户端的地址信息(ip,port)
        - addrlen : 指定第二个参数的对应的内存大小
    - 返回值:
        - 成功 :用于通信的文件描述符
        - -1 : 失败
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);    
    - 功能: 客户端连接服务器
    - 参数:
        - sockfd : 用于通信的文件描述符
        - addr : 客户端要连接的服务器的地址信息
        - addrlen : 第二个参数的内存大小
    - 返回值:成功 0, 失败 -1
ssize_t write(int fd, const void *buf, size_t count); // 写数据
ssize_t read(int fd, void *buf, size_t count); // 读数据

ssize_t recv(int socket, void *buf, size_t len, int flags)
    功能:recv()所做的工作,就是把内核缓冲区中的数据拷贝到应用层用户的buffer里面,并返回拷贝的字节数。(注意:是拷贝,不是像read那样读取之后,清空接受缓冲区内的数据。)
    参数一:指定接收端套接字描述符;
    参数二:指向一个缓冲区,该缓冲区用来存放recv函数接收到的数据;
    参数三:指明buf的长度;
    参数四:一般置为0;
    返回值:失败时,返回值小于0;超时或对端主动关闭,返回值等于0;成功时,返回值是返回接收数据的长度。
ssize_t send(int socket, const void *buf, size_t len, int flags);  
	功能:send()仅仅是把应用层buffer的数据拷贝到socket的内核发送缓冲区中,发送是TCP的事情。(注意:这里也是拷贝,不是像write那样发送之后,清空发送缓冲区内的数据。)
    参数一:指定发送端套接字描述符;
    参数二:指明一个存放应用程序要发送数据的缓冲区;
    参数三:指明实际要发送的数据的字节数;
    参数四:一般置0;
    返回值:失败时,返回值小于0;超时或对端主动关闭,返回值等于0;成功时,返回值是返回发送数据的长度。


03 / TCP 三次握手

TCP 是一种面向连接的单播协议,在发送数据前,通信双方必须在彼此间建立一条连接。所谓的“连接”,其实是客户端和服务器的内存里保存的一份关于对方的信息,如 IP 地址、端口号等。

TCP 可以看成是一种字节流,它会处理 IP 层或以下的层的丢包、重复以及错误问题。在连接的建立过程中,双方需要交换一些连接的参数。这些参数可以放在 TCP 头部。

TCP 提供了一种可靠、面向连接、字节流、传输层的服务,采用三次握手建立一个连接。采用四次挥手来关闭一个连接。

三次握手的目的是保证双方互相之间建立了连接。

三次握手发生在客户端连接的时候,当调用connect(),底层会通过TCP协议进行三次握手

image-20230217094045272

  • 16 位端口号(port number):告知主机报文段是来自哪里(源端口)以及传给哪个上层协议或应用程序(目的端口)的。进行 TCP 通信时,客户端通常使用系统自动选择的临时端口号。

  • 32 位序号(sequence number):一次 TCP 通信(从 TCP 连接建立到断开)过程中某一个传输方向上的字节流的每个字节的编号。假设主机 A 和主机 B 进行 TCP 通信,A 发送给 B 的第一个TCP 报文段中,序号值被系统初始化为某个随机值 ISN(Initial Sequence Number,初始序号值)。那么在该传输方向上(从 A 到 B),后续的 TCP 报文段中序号值将被系统设置成 ISN 加上该报文段所携带数据的第一个字节在整个字节流中的偏移。例如,某个 TCP 报文段传送的数据是字节流中的第 1025 ~ 2048 字节,那么该报文段的序号值就是 ISN + 1025。另外一个传输方向(从B 到 A)的 TCP 报文段的序号值也具有相同的含义。

  • 32 位确认号(acknowledgement number):用作对另一方发送来的 TCP 报文段的响应。其值是收到的 TCP 报文段的序号值 + 标志位长度(SYN,FIN) + 数据长度 。假设主机 A 和主机 B 进行TCP 通信,那么 A 发送出的 TCP 报文段不仅携带自己的序号,而且包含对 B 发送来的 TCP 报文段的确认号。反之,B 发送出的 TCP 报文段也同样携带自己的序号和对 A 发送来的报文段的确认序号。

  • 4 位头部长度(head length):标识该 TCP 头部有多少个 32 bit(4 字节)。因为 4 位最大能表示15,所以 TCP 头部最长是60 字节。

  • 6 位标志位包含如下几项:

    • URG 标志,表示紧急指针(urgent pointer)是否有效。
    • ACK 标志,表示确认号是否有效。我们称携带 ACK 标志的 TCP 报文段为确认报文段。
    • PSH 标志,提示接收端应用程序应该立即从 TCP 接收缓冲区中读走数据,为接收后续数据腾出空间(如果应用程序不将接收到的数据读走,它们就会一直停留在 TCP 接收缓冲区中)。
    • RST 标志,表示要求对方重新建立连接。我们称携带 RST 标志的 TCP 报文段为复位报文段。
    • SYN 标志,表示请求建立一个连接。我们称携带 SYN 标志的 TCP 报文段为同步报文段。
    • FIN 标志,表示通知对方本端要关闭连接了。我们称携带 FIN 标志的 TCP 报文段为结束报文段。
  • 16 位窗口大小(window size):是 TCP 流量控制的一个手段。这里说的窗口,指的是接收通告窗口(Receiver Window,RWND)。它告诉对方本端的 TCP 接收缓冲区还能容纳多少字节的数据,这样对方就可以控制发送数据的速度

  • 16 位校验和(TCP checksum):由发送端填充,接收端对 TCP 报文段执行 CRC 算法以校验TCP 报文段在传输过程中是否损坏。注意,这个校验不仅包括 TCP 头部,也包括数据部分。这也是 TCP 可靠传输的一个重要保障

  • 16 位紧急指针(urgent pointer):是一个正的偏移量。它和序号字段的值相加表示最后一个紧急数据的下一个字节的序号。因此,确切地说,这个字段是紧急指针相对当前序号的偏移,不妨称之为紧急偏移。TCP 的紧急指针是发送端向接收端发送紧急数据的方法。

image-20230217101147501

第一次握手:
1.客户端将SYN标志位置为1
2.生成一个随机的32位的序号seq=x , 这个序号后边是可以携带数据(数据的大小)
第二次握手:
1.服务器端接收客户端的连接: ACK=1.(ACK标志位)
2.服务器会回发一个确认序号: ack=客户端的序号 + 数据长度 + SYN/FIN(按一个字节算) (32 位确认号acknowledgement number)
3.服务器端会向客户端发起连接请求: SYN=1
4.服务器会生成一个随机序号:seq = K
第三次握手:
1.客户单应答服务器的连接请求:ACK=1
2.客户端回复收到了服务器端的数据:ack=服务端的序号 + 数据长度 + SYN/FIN(按一个字节算)

04 / TCP 滑动窗口

滑动窗口(Sliding window)是一种流量控制技术。早期的网络通信中,通信双方不会考虑网络的拥挤情况直接发送数据。由于大家不知道网络拥塞状况,同时发送数据,导致中间节点阻塞掉包,谁也发不了数据,所以就有了滑动窗口机制来解决此问题。滑动窗口协议是用来改善吞吐量的一种技术,即容许发送方在接收任何应答之前传送附加的包。接收方告诉发送方在某一时刻能送多少包(称窗口尺寸)。

TCP 中采用滑动窗口来进行传输控制,滑动窗口的大小意味着接收方还有多大的缓冲区可以用于接收数据。发送方可以通过滑动窗口的大小来确定应该发送多少字节的数据。当滑动窗口为 0时,发送方一般不能再发送数据报。

滑动窗口是 TCP 中实现诸如 ACK 确认、流量控制、拥塞控制的承载结构。

窗口理解为缓冲区的大小
滑动窗口的大小会随着发送数据和接收数据而变化。
通信的双方都有发送缓冲区和接收数据的缓冲区
服务器:
发送缓冲区(发送缓冲区的窗口)
接收缓冲区(接收缓冲区的窗口)
客户端
发送缓冲区(发送缓冲区的窗口)
接收缓冲区(接收缓冲区的窗口)

image-20230217101857758

发送方的缓冲区:
    白色格子:空闲的空间
    灰色格子:数据已经被发送出去了,但是还没有被接收
    紫色格子:还没有发送出去的数据
接收方的缓冲区:
    白色格子:空闲的空间
    紫色格子:已经接收到的数据

image-20230217102043715

# mss: Maximum Segment Size(一条数据的最大的数据量)
# win: 滑动窗口
1. 客户端向服务器发起连接,客户单的滑动窗口是4096,一次发送的最大数据量是1460
2. 服务器接收连接情况,告诉客户端服务器的窗口大小是6144,一次发送的最大数据量是1024
3. 第三次握手
4. 4-9 客户端连续给服务器发送了6k的数据,每次发送1k
5. 第10次,服务器告诉客户端:发送的6k数据已经接收到,存储在缓冲区中,缓冲区数据已经处理了2k,窗口大小是2k
6. 第11次,服务器告诉客户端:发送的6k数据已经接收到,存储在缓冲区中,缓冲区数据已经处理了4k,窗口大小是4k
7. 第12次,客户端给服务器发送了1k的数据
8. 第13次,客户端主动请求和服务器断开连接,并且给服务器发送了1k的数据
9. 第14次,服务器回复ACK 8194, a:同意断开连接的请求 b:告诉客户端已经接受到方才发的2k的数据c:滑动窗口2k
10.第15、16次,通知客户端滑动窗口的大小
11.第17次,第三次挥手,服务器端给客户端发送FIN,请求断开连接
12.第18次,第四次挥手,客户端同意了服务器端的断开请求

05 / TCP 四次挥手

四次挥手发生在断开连接的时候,在程序中当调用了close()会使用TCP协议进行四次挥手
客户端和服务器端都可以主动发起断开连接谁先调用close()谁就是发起
因为在TCP连接的时候,采用三次握手建立的的连接是双向的,在断开的时候需要双向断开

image-20230217103123330

image-20230217103213048

06 /TCP 状态转换

image-20230218101718092

image-20230218095524184

2MSL(Maximum Segment Lifetime)
主动断开连接的一方, 最后进入一个 TIME_WAIT状态, 这个状态会持续: 2msl msl: 官方建议: 2分钟, 实际是30s

  • 当 TCP 连接主动关闭方接收到被动关闭方发送的 FIN 和最终的 ACK 后,连接的主动关闭方必须处于TIME_WAIT 状态**并持续 2MSL 时间(1分钟)**。
  • 这样就能够让 TCP 连接的主动关闭方在它发送的 ACK 丢失的情况下重新发送最终的 ACK
  • 主动关闭方重新发送的最终 ACK 并不是因为被动关闭方重传了 ACK(它们并不消耗序列号,被动关闭方也不会重传),而是因为被动关闭方重传了它的 FIN。事实上,被动关闭方总是重传 FIN 直到它收到一个最终的 ACK。

半关闭
当 TCP 链接中 A 向 B 发送 FIN 请求关闭,另一端 B 回应 ACK 之后(A 端进入 FIN_WAIT_2状态),并没有立即发送 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): 关闭sockfd上的读功能,此选项将不允许sockfd进行读操作。该套接字不再接收数据,任何当前在套接字接受缓冲区的数据将被无声的丢弃掉。
        SHUT_WR(1): 关闭sockfd的写功能,此选项将不允许sockfd进行写操作。进程不能在对此套接字发出写操作。
        SHUT_RDWR(2):关闭sockfd的读写功能。相当于调用shutdown两次:首先是以SHUT_RD,然后以SHUT_WR。

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

注意:

  1. 如果有多个进程共享一个套接字,close 每被调用一次,计数减 1 ,直到计数为 0 时,也就是所用进程都调用了 close,套接字将被释放。
  2. 多进程中如果一个进程调用了 shutdown(sfd, SHUT_RDWR) 后,其它的进程将无法进行通信。但如果一个进程 close(sfd) 将不会影响到其它进程

4.6 TCP 通信与并发案列

01 / TCP单进程,单线程通信代码

// TCP 通信的服务器端(单进程,单线程,不做并发处理)
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <errno.h>

int main() {
    char serIP[]={"192.168.73.101"};
    unsigned short serPort=9090;   //0-65536  2字节

    // 1.socket打开文件描述符
    int lfd = socket(PF_INET,SOCK_STREAM,0); // link file description
    if(lfd==-1){ perror("socket"); exit(-1);}

    // 2.bind绑定我的网址和端口

    //封装服务器的IP地址和端口
    struct sockaddr_in sevInfo;
    sevInfo.sin_family=AF_INET;    //ipv4
    sevInfo.sin_addr.s_addr = INADDR_ANY;  // 0.0.0.0
    // inet_pton(AF_INET,serIP,&sevInfo.sin_addr.s_addr);   //ip转换为大端(网络端)
    sevInfo.sin_port = htons(serPort);

    // bind绑定我的网址和端口
    int ret = bind(lfd,(struct sockaddr *)&sevInfo,sizeof(sevInfo));
    if(ret==-1){ perror("bind"); exit(-1);}

    // 3.listen监听端口
    ret = listen(lfd,20);    //最多连接20个客户端  有一个连接队列,后面的accept每次取一个客户
    if(ret==-1){ perror("listen"); exit(-1);}

//三次握手发生在哪个api里面?
//不发生在哪个API,主要是在listen之后,有客户端来做三次握手(发生在协议栈中)。


    // 4.接收客户端连接
    struct sockaddr_in clientInfo;
    socklen_t len=sizeof(clientInfo);
    int cfd = accept(lfd,(struct sockaddr *) &clientInfo,&len);  // communication file description
    if(cfd==-1){ perror("accept"); exit(-1);}

    // 输出客户端的信息
    char cInfoIP[16]={0};
    unsigned short cInfoPort = ntohs(clientInfo.sin_port);
    inet_ntop(AF_INET,&clientInfo.sin_addr.s_addr,cInfoIP,sizeof(cInfoIP));
    printf("clirnt IP address is : %s\n",cInfoIP);
    printf("clirnt IP port is : %d\n",cInfoPort);

    //回射服务器
    // 5.与客户端收发数据(单进程,单线程,不做并发处理)
    char recvSendBuf[1024]={0};
    while(1){

        ret = recv(cfd,&recvSendBuf,sizeof(recvSendBuf),MSG_ERRQUEUE);
        if(ret>0){
            printf("server receive data :%s\n",recvSendBuf);
        }else if(ret==0){
            printf("client die\n");
            break;
        }else if(ret==-1){
            perror("recv"); exit(-1);
        }

        ret = write(cfd,&recvSendBuf,sizeof(recvSendBuf));
        if(ret==-1){ perror("write"); exit(-1);}
        memset(recvSendBuf, 0, sizeof(recvSendBuf));
        // sleep(1);
    }
    close(cfd);
    close(lfd);

    //双方键盘输入发送
    // 5.与客户端收发数据(单进程,单线程,不做并发处理)
    // char recvBuf[1024]={0};
    // char keyContent[1024]={0};
    // while(1){
    //     ret = read(cfd,&recvBuf,sizeof(recvBuf));
    //     if(ret>0){
    //         printf("server receive data :%s\n",recvBuf);
    //     }else if(ret==0){
    //         printf("client die\n");
    //         break;
    //     }else if(ret==-1){
    //         perror("read"); exit(-1);
    //     }
    //     memset(recvBuf,0,sizeof(keyContent));

    //     printf("请输入发给客户端的信息\n");
    //     memset(keyContent,0,sizeof(keyContent));
    //     scanf("%s",keyContent);
    //     ret = write(cfd,(char*)&keyContent,sizeof(keyContent));
    //     if(ret==-1){ perror("read"); exit(-1);}
    // }
    // close(cfd);
    // close(lfd);

    return 0;
}

// TCP通信的客户端

#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>

int main() {
    char serverIP[]={"192.168.73.100"};
    unsigned short serPort=9090;   //0-65536  2字节

    // 1.创建套接字
    int cfd =socket(AF_INET,SOCK_STREAM,0);
    if(cfd==-1){ perror("socket"); exit(-1);}

    // 2.connect 申请连接
    struct sockaddr_in serverInfo;
    serverInfo.sin_family = AF_INET;
    inet_pton(AF_INET,serverIP,&serverInfo.sin_addr.s_addr);
    serverInfo.sin_port=htons(serPort);
    
    int ret = connect(cfd,(struct sockaddr*) &serverInfo,sizeof(serverInfo));
    if(ret==-1){ perror("connect"); exit(-1);}

    // 3.连接成功之后发收数据
    unsigned short i=1;
    char sendData[1024]={0};
    char receiveData[1024]={0};
    while(1){
         //发数据
        sprintf((char *)&sendData,"I am cleient %d",i++);
        ret = write(cfd,(void*)&sendData,strlen(sendData));
        if(ret==-1){ perror("write"); exit(-1);}

        //收数据
        ret = read(cfd,&receiveData,sizeof(receiveData));
        if(ret==-1){ 
            perror("read"); 
            exit(-1);
        }else if(ret>0){
            printf("cleient receive data:%s\n",receiveData);
        }else if(ret==0){
            printf("server die\n");
            break;
        }
        sleep(1);   //尽量让中断发生在睡眠的时候,要不然服务器那边读数据会有问题
    }

    // 4.关闭文件描述符
    close(cfd);
    return 0;
}

02 / TCP多进程通信代码

1.一个父进程,多个子进程
2.父进程负责等待并接受客户端的连接
3.子进程:完成通信,接受一个客户端连接,就创建一个子进程用于通信。

// TCP通信的客户端
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>

int main() {
    char serverIP[]={"192.168.73.100"};
    unsigned short serverPort=9090;

    // 1.创建套接字
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if(fd == -1) { perror("socket");  exit(-1);}

    // 2.连接服务器端
    struct sockaddr_in serveraddr;
    serveraddr.sin_family = AF_INET;
    inet_pton(AF_INET, serverIP, &serveraddr.sin_addr.s_addr);
    serveraddr.sin_port = htons(serverPort);
    
    int ret = connect(fd, (struct sockaddr *)&serveraddr, sizeof(serveraddr));
    if(ret == -1) { perror("connect"); exit(-1); }
    
    // 3. 通信
    char recvBuf[1024];
    int i = 0;
    while(1) {
        memset(recvBuf,0,sizeof(recvBuf));
        sprintf(recvBuf, "data : %d", i++);
        sleep(1);   //这个只是让程序发送的慢一些//尽量让中断(ctrl+c)发生在睡眠的时候,要不然服务器那边读数据会有问题
        
        // 给服务器端发送数据
        write(fd, recvBuf, strlen(recvBuf)+1);

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

    // 关闭连接
    close(fd);
    return 0;
}
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <errno.h>

void recycleChild(int arg){
    while(1){
        pid_t cpid = waitpid(-1, NULL, WNOHANG);
        if(cpid==-1){ //错误,或者没有子进程了
            break;
        }else if(cpid==0){  //表示还有子进程活着
            break;
        }
        else if(cpid>0){ //还有子进程了
            printf("回收的子进程是:%d\n",cpid);
        }
    }
}

int main() {
    char serverIP[] = {"192.168.73.101"};
    unsigned short serverPort = 9090;

    // 父进程负责回收子进程。
    struct sigaction act;
    act.sa_flags=0;  //sa_flags是0,表示使用sa_handler
    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.封装端口和IP
    struct sockaddr_in serverInfo;
    serverInfo.sin_family=AF_INET;
    // inet_pton(AF_INET,serverIP,&serverInfo.sin_addr.s_addr);
    serverInfo.sin_addr.s_addr = INADDR_ANY;
    serverInfo.sin_port = htons(serverPort);

    // 3.bind绑定IP和端口信息
    int ret = bind(lfd,(struct sockaddr *)&serverInfo,sizeof(serverInfo)) ;
    if(ret==-1){ perror("bind");  exit(-1); }

    // 4.listen监听端口
    ret = listen(lfd,8);
    if(ret==-1){ perror("listen");  exit(-1); }

    while(1){
        // 5.accept接收连接并创建子进程 ,子进程负责通信
        struct sockaddr_in clientInfo;
        socklen_t len = sizeof(clientInfo);
        int cfd = accept(lfd,(struct sockaddr *)&clientInfo,&len);
        if(cfd==-1){
            if(errno == EINTR) {    //被信号中断了
                continue;
            }
            perror("accept");  
            exit(-1); 
        }

        pid_t pid = fork();
        if(pid==0){ // 子进程
            // 获取客户端的信息
            char cliIp[16];
            inet_ntop(AF_INET, &clientInfo.sin_addr.s_addr, cliIp, sizeof(cliIp));
            unsigned short cliPort = ntohs(clientInfo.sin_port);
            printf("client ip is : %s, prot is %d\n", cliIp, cliPort);

            char receiveBuf[1024];
            unsigned short i=1;
            char sendBuf[1024];
            while(1){
                //读取数据
                memset(receiveBuf,0,sizeof(receiveBuf));
                int len = read(cfd,&receiveBuf,sizeof(receiveBuf));
                if(len==-1){   //出现异常
                    perror("read");  
                    break; 
                }else if(len==0){  //客户端断开连接
                    printf("client close...\n");
                    break;
                }else if(len>0){
                    printf("server receive info:  %s\n",receiveBuf);
                }

                //发送数据
                memset(sendBuf,0,sizeof(sendBuf));
                sprintf(sendBuf,"I am server %d\n",i++);
                len = write(cfd,sendBuf,sizeof(sendBuf));
                if(len==-1){ perror("write");  break; }
            }
            close(cfd);
            exit(0);    // 退出当前子进程
        }
    }
    close(lfd);
    return 0;
}

03 / TCP多线程通信代码

//这个比较正确
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>

// 定义最大连接数为3
const int maxLink = 3; //C里只能用#define定义常量,但C++却可以用const。

struct communPara{
    struct sockaddr_in clientInfo;
    int cfd ;
    pthread_t tid;
};
struct communPara ccp[maxLink];  //与客户端通信参数 client communication parameters

void* communication(void * arg){ 
    // 子线程和客户端通信   cfd 客户端的信息 线程号
    // 获取客户端的信息
    struct communPara* ccp=(struct communPara*)arg;
    
    //输出客户端信息
    char lientIP[16]={0};
    inet_ntop(AF_INET,&ccp->clientInfo.sin_addr.s_addr,lientIP,sizeof(lientIP));
    unsigned short clientPort=ntohs(ccp->clientInfo.sin_port);
    printf("client's IP is:%s,  client's port is:%d\n",lientIP,clientPort);
    sleep(0.5);

    // 1.通信
    char recvSendBuf[1024];
    while(1){
        //清空缓存
        memset(recvSendBuf,0,sizeof(recvSendBuf));
        int len = read(ccp->cfd,recvSendBuf,sizeof(recvSendBuf));
        if(len==-1){
            perror("read");
            break;//就算有问题也要去释放资源
        }else if(len>0){
            printf("server receive data: %s\n",recvSendBuf);
        }else if(len==0){
            printf("%ld客户端断开\n",ccp->tid);
            break;
        }
        write(ccp->cfd,recvSendBuf,sizeof(recvSendBuf));
    }

    // 2.释放文件描述符
    close(ccp->cfd);

    // 3.将传入的结构体清零;
    bzero(&ccp->clientInfo,sizeof(ccp->clientInfo));
    ccp->cfd=-1;
    ccp->tid=-1;
    return NULL;
}

int main() {
    char serverIP[] = {"192.168.73.101"};
    unsigned short serverPort = 9090;

    // 1.创建socket,获取连接文件描述符
    int lfd = socket(AF_INET,SOCK_STREAM,0);
    if(lfd == -1){ perror("socket");  exit(-1);}

    // 2.封装端口
    struct sockaddr_in serverInfo;
    serverInfo.sin_family = AF_INET;
    serverInfo.sin_addr.s_addr = INADDR_ANY;
    serverInfo.sin_port = htons(serverPort);

    // 3.bind绑定服务器IP和端口,和文件描述符
    int ret = bind(lfd,(struct sockaddr*) &serverInfo,sizeof(serverInfo));
    if(ret == -1){ perror("bind");  exit(-1);}

    // 4.listen监听端口
    ret = listen(lfd,maxLink);
    if(ret == -1){ perror("listen");  exit(-1);}
           
    // 初始化数据
    int max=sizeof(ccp)/sizeof(ccp[0]);
    for(int i=0;i<max;++i){
        bzero(&ccp[i],sizeof(ccp[i]));
        ccp[i].cfd=-1;
        ccp[i].tid=-1;
    }

    //反复遍历ccp数组找到可用的,(大家可以考虑释放资源的序号存放在堆栈或者队列中,
    //我们只要去使用其中标记为-1的资源)
    while(1){
        struct sockaddr_in clientInfo;
        socklen_t len = sizeof(clientInfo);
        int cfd=accept(lfd,(struct sockaddr*)&clientInfo,&len);//阻塞函数
        
        // 从这个数组中找到一个可以用的sockInfo元素
        struct communPara *pinfo;
        for(int i=0;i<max;++i){
            if(ccp[i].cfd==-1){
                pinfo=&ccp[i];
                break;
            }
            if(i==max-1){
                sleep(1);
                i=-1;
            }
        }

        pinfo->cfd=cfd;
        memcpy(&pinfo->clientInfo,&clientInfo,len);
        // 创建子线程
        pthread_create(&pinfo->tid, NULL, communication, pinfo);
        pthread_detach(pinfo->tid);
    }
    // for(int i=0;i<max;++i){  
    //     // if(ccp[i].tid!=-1){ //有数据证明不可用
    //     //     if(i==max-1) {
    //     //         i=-1;
    //     //         sleep(2);
    //     //     }
    //     //     continue;
    //     // }else{
    //     //     // 5.accept接受客户端连接(循环里)
    //     //     socklen_t len = sizeof(ccp[i].clientInfo);
    //     //     ccp[i].cfd=accept(lfd,(struct sockaddr*)&ccp[i].clientInfo,&len);
    //     //     if(ccp[i].cfd==-1){ perror("accept");  continue;}

    //     //     // 6.创建子线程负责通信(参数:clientInfo,cfd,tid)
    //     //     //pthread_create函数不会让主线程不阻塞,所以可以在communication中释放资源
    //     //     pthread_create(&ccp[i].tid,NULL,communication,&ccp[i]);
    //     //     pthread_detach(ccp[i].tid);
    //     }
    // }
    close(lfd);
    return 0;
}
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>


/*
优点:
逻辑简单,
缺点:
很难突破C10K  按道理4G的运行内存有512左右客户端处理
*/

struct communPara{
    struct sockaddr_in clientInfo;
    int cfd ;   // 通信的文件描述符
    pthread_t tid; // 线程号
};
struct communPara ccp[5];  //与客户端通信参数 client communication parameters

void * communication(void * arg){
    // 1.通信
    struct communPara* pinfo=(struct communPara*)arg;

    char clientIP[16];
    inet_ntop(AF_INET,&pinfo->clientInfo.sin_addr.s_addr,clientIP,sizeof(clientIP));
    unsigned short clientPort = ntohs(pinfo->clientInfo.sin_port);
    printf("客户端的IP: %s   客户端的端口: %d\n",clientIP,clientPort);
    
    // 接收客户端发来的数据
    char recvBuf[1024];
    while(1) {
        int len = read(pinfo->cfd, &recvBuf, sizeof(recvBuf));
        if(len == -1) {
            perror("read");
            exit(-1);
        }else if(len > 0) {
            printf("recv client : %s\n", recvBuf);
        } else if(len == 0) {
            printf("client closed....\n");
            break;
        }
        write(pinfo->cfd, recvBuf, strlen(recvBuf)+1);
    }
    
    // 2.释放文件描述符
    close(pinfo->cfd);
    return NULL;
}

int main() {
    char serverIP[] = {"192.168.73.100"};
    unsigned short serverPort = 9090;

    // 1.创建socket,获取连接文件描述符
    int lfd = socket(AF_INET,SOCK_STREAM,0);
    if(lfd == -1){ perror("socket");  exit(-1);}

    // 2.封装端口
    struct sockaddr_in serverInfo;
    serverInfo.sin_family = AF_INET;
    serverInfo.sin_addr.s_addr = INADDR_ANY;
    serverInfo.sin_port = htons(serverPort);

    // 3.bind绑定服务器IP和端口,和文件描述符
    int ret = bind(lfd,(struct sockaddr*) &serverInfo,sizeof(serverInfo));
    if(ret == -1){ perror("bind");  exit(-1);}

    // 4.listen监听端口
    ret = listen(lfd,5);
    if(ret == -1){ perror("listen");  exit(-1);}

    // 初始化数据
    int max= sizeof(ccp)/sizeof(ccp[0]);
    for(int i=0; i<max; i++){
        bzero(&ccp[i],sizeof(ccp[i]));
        ccp[i].cfd = -1;
        ccp[i].tid = -1;
    }
    while(1){
        // 5.accept接受客户端连接(循环里)
        // 6.创建子线程负责通信(参数:clientInfo,cfd,tid)
        struct sockaddr_in clientInfo;
        socklen_t len = sizeof(clientInfo);
        int cfd = accept(lfd,(struct sockaddr*)&clientInfo,&len);
        
        struct communPara * pinfo;
        for(int i=0; i<max; i++){
            // 从这个数组中找到一个可以用的sockInfo元素
            if(ccp[i].cfd == -1){
                pinfo=&ccp[i];
                break;
            }
            if(i == max-1){
                sleep(1);
                i = -1; //继续从0开始遍历查找可用的客户端通信参数ccp
            }
        }

        pinfo->cfd = cfd;
        memcpy(&pinfo->clientInfo,&clientInfo,len);
        pthread_create(&pinfo->tid,NULL,communication,pinfo);
        pthread_detach(pinfo->tid);  //??用完应该释放指针吧
    }

    // 7.回收子线程或者与子线程分离。
    close(lfd);
    return 0;
}

04 / 实现简单地小写转大写回射服务器

#include <stdio.h>
#include <ctype.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char *argv[]) {

    // 创建socket
    int lfd = socket(PF_INET, SOCK_STREAM, 0);
    if(lfd == -1) {
        perror("socket");
        return -1;
    }

    struct sockaddr_in saddr;
    saddr.sin_family = AF_INET;
    saddr.sin_addr.s_addr = INADDR_ANY;
    saddr.sin_port = htons(9999);

    int optval = 1;//设置端口复用
    setsockopt(lfd, SOL_SOCKET, SO_REUSEPORT, &optval, sizeof(optval));

    // 绑定
    int ret = bind(lfd, (struct sockaddr *)&saddr, sizeof(saddr));
    if(ret == -1) {
        perror("bind");
        return -1;
    }

    // 监听
    ret = listen(lfd, 8);
    if(ret == -1) {
        perror("listen");
        return -1;
    }

    // 接收客户端连接
    struct sockaddr_in cliaddr;
    socklen_t len = sizeof(cliaddr);
    int cfd = accept(lfd, (struct sockaddr *)&cliaddr, &len);
    if(cfd == -1) {
        perror("accpet");
        return -1;
    }

    // 获取客户端信息
    char cliIp[16];
    inet_ntop(AF_INET, &cliaddr.sin_addr.s_addr, cliIp, sizeof(cliIp));
    unsigned short cliPort = ntohs(cliaddr.sin_port);

    // 输出客户端的信息
    printf("client's ip is %s, and port is %d\n", cliIp, cliPort );

    // 接收客户端发来的数据
    char recvBuf[1024] = {0};
    while(1) {
        int len = recv(cfd, recvBuf, sizeof(recvBuf), 0);
        if(len == -1) {
            perror("recv");
            return -1;
        } else if(len == 0) {
            printf("客户端已经断开连接...\n");
            break;
        } else if(len > 0) {
            printf("read buf = %s\n", recvBuf);
        }

        // 小写转大写
        for(int i = 0; i < len; ++i) {
            recvBuf[i] = toupper(recvBuf[i]);
        }

        printf("after buf = %s\n", recvBuf);

        // 大写字符串发给客户端
        ret = send(cfd, recvBuf, strlen(recvBuf) + 1, 0);
        if(ret == -1) {
            perror("send");
            return -1;
        }
    }
    
    close(cfd);
    close(lfd);

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

int main() {

    // 创建socket
    int fd = socket(PF_INET, SOCK_STREAM, 0);
    if(fd == -1) {
        perror("socket");
        return -1;
    }

    struct sockaddr_in seraddr;
    inet_pton(AF_INET, "127.0.0.1", &seraddr.sin_addr.s_addr);
    seraddr.sin_family = AF_INET;
    seraddr.sin_port = htons(9999);

    // 连接服务器
    int ret = connect(fd, (struct sockaddr *)&seraddr, sizeof(seraddr));

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

    while(1) {
        char sendBuf[1024] = {0};
        // fgets(sendBuf, sizeof(sendBuf), stdin);  //从键盘读取输入,阻塞函数
        printf("请输入需要转换为大写的字符\n");
        scanf("%s",&sendBuf);

        write(fd, sendBuf, strlen(sendBuf) + 1);

        // 接收
        int len = read(fd, sendBuf, sizeof(sendBuf));
        if(len == -1) {
            perror("read");
            return -1;
        }else if(len > 0) {
            printf("read buf = %s\n", sendBuf);
        } else {
            printf("服务器已经断开连接...\n");
            break;
        }
    }

    close(fd);
    return 0;
}

05 / 端口复用

端口复用最常用的用途是:

  • 防止服务器重启时之前绑定的端口还未释放
  • 程序突然退出而系统没有释放端口
#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 端口复用
        - SO_LINGER 即让没发完的数据发送出去后在关闭socket
    - optval : 端口复用的值(整形)
        - 1 : 可以复用
        - 0 : 不可以复用
    - optlen : optval参数的大小
端口复用,设置的时机是在服务器绑定端口之前。
setsockopt();
bind();


SO_LINGER选项用来改变此缺省设置。使用如下结构:
struct linger {
    int l_onoff;
    int l_linger;
};
有下列三种情况:
- l_onoff为0,则该选项关闭,l_linger的值被忽略,等于缺省情况,close立即返回;
- l_onoff为非0,l_linger为0,则套接口关闭时TCP夭折连接,TCP将丢弃保留在套接口发送缓冲区中的任何数据并发送一个RST 给对方,
    而不是通常的四分组终止序列,这避免了TIME_WAIT状态;
- l_onoff 为非0,l_linger为非0,当套接口关闭时内核将拖延一段时间(由l_linger决定)。如果套接口缓冲区中仍残留数据,进程将处于睡眠状态,
    直 到(a)所有数据发送完且被对方确认,之后进行正常的终止序列(描述字访问计数为0)或(b)延迟时间到。此种情况下,应用程序检查close的返回值是 
    非常重要的,如果在数据发送完并被确认前时间到,close将返回EWOULDBLOCK错误且套接口发送缓冲区中的任何数据都丢失。
    close的成功返回仅告诉我们发送的数据(和FIN)已由对方TCP确认,它并不能告诉我们对方应用进程是否已读了数据。如果套接口设为非阻塞的,
    它将不等待close完 成。
    
l_linger的单位依赖于实现,4.4BSD假设其单位是时钟滴答(百分之一秒),但Posix.1g规定单位为秒。

06 / 常看网络相关信息的命令

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

4.7 UDP

01 / UDP 通信

数据报方式分发数据

image-20230218103120772

#include <sys/types.h>
#include <sys/socket.h>
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,const struct sockaddr *dest_addr, socklen_t addrlen);
    - 参数:
        - sockfd : 通信的fd
        - buf : 要发送的数据
        - len : 发送数据的长度
        - 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 : 地址的内存大小
// 客户端
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>

int main() {

    // 1.创建一个通信的socket
    int fd = socket(PF_INET, SOCK_DGRAM, 0);	//报式协议
    if(fd == -1) { perror("socket"); exit(-1); }   

    // 服务器的地址信息
    struct sockaddr_in saddr;
    saddr.sin_family = AF_INET;
    saddr.sin_port = htons(9999);
    inet_pton(AF_INET, "127.0.0.1", &saddr.sin_addr.s_addr);

    int num = 0;
    // 3.通信
    while(1) {

        // 发送数据
        char sendBuf[128];
        sprintf(sendBuf, "hello , i am client %d \n", num++);
        sendto(fd, sendBuf, strlen(sendBuf) + 1, 0, (struct sockaddr *)&saddr, sizeof(saddr));

        // 接收数据
        int num = recvfrom(fd, sendBuf, sizeof(sendBuf), 0, NULL, NULL);
        printf("server say : %s\n", sendBuf);
        sleep(1);
    }

    close(fd);
    return 0;
}
// 服务端
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>

int main() {
    // 1.创建一个通信的socket
    int fd = socket(PF_INET, SOCK_DGRAM, 0);
    if(fd == -1) { perror("socket"); exit(-1); }   

    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(9999);
    addr.sin_addr.s_addr = INADDR_ANY;

    // 2.绑定
    int ret = bind(fd, (struct sockaddr *)&addr, sizeof(addr));
    if(ret == -1) {  perror("bind"); exit(-1); }

    // 3.通信
    while(1) {
        char recvbuf[128];
        char ipbuf[16];
        
        struct sockaddr_in cliaddr;  //客户端的地址信息
        int len = sizeof(cliaddr);

        // 接收数据
        int num = recvfrom(fd, recvbuf, sizeof(recvbuf), 0, (struct sockaddr *)&cliaddr, &len);

        printf("client IP : %s, Port : %d\n", 
            inet_ntop(AF_INET, &cliaddr.sin_addr.s_addr, ipbuf, sizeof(ipbuf)),
            ntohs(cliaddr.sin_port));

        printf("client say : %s\n", recvbuf);

        // 发送数据
        sendto(fd, recvbuf, strlen(recvbuf) + 1, 0, (struct sockaddr *)&cliaddr, sizeof(cliaddr));
    }

    close(fd);
    return 0;
}

02 / 广播

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

  • a.只能在局域网中使用
  • b.客户端需要绑定服务器广播使用的端口,才可以接收到广播消息

image-20230218103501622

// 设置广播属性的函数(也可以设置端口复用)
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的大小
//服务器再喊
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>

int main() {

    // 1.创建一个通信的socket
    int fd = socket(PF_INET, SOCK_DGRAM, 0);
    if(fd == -1) { perror("socket"); exit(-1); }   

    // 2.设置广播属性
    int op = 1;
    setsockopt(fd, SOL_SOCKET, SO_BROADCAST, &op, sizeof(op));
    
    // 3.创建一个广播的地址
    struct sockaddr_in cliaddr;
    cliaddr.sin_family = AF_INET;
    cliaddr.sin_port = htons(9999);
    inet_pton(AF_INET, "192.168.73.255", &cliaddr.sin_addr.s_addr);

    // 3.通信
    int num = 0;
    while(1) {
       
        char sendBuf[128];
        sprintf(sendBuf, "hello, client....%d\n", num++);
        // 发送数据
        sendto(fd, sendBuf, strlen(sendBuf) + 1, 0, (struct sockaddr *)&cliaddr, sizeof(cliaddr));
        printf("广播的数据:%s\n", sendBuf);
        sleep(1);
    }

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

int main() {

    // 1.创建一个通信的socket
    int fd = socket(PF_INET, SOCK_DGRAM, 0);
    if(fd == -1) {perror("socket"); exit(-1);    }   

    struct in_addr in;
    // 2.客户端绑定本地的IP和端口
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(9999);
    addr.sin_addr.s_addr = INADDR_ANY;

    int ret = bind(fd, (struct sockaddr *)&addr, sizeof(addr));
    if(ret == -1) { perror("bind");  exit(-1);}

    // 3.通信
    while(1) {
        char buf[128];
        // 接收数据
        int num = recvfrom(fd, buf, sizeof(buf), 0, NULL, NULL);
        printf("server say : %s\n", buf);
    }
    
    close(fd);
    return 0;
}

03 / 组播(多播)

单播地址标识单个 IP 接口,广播地址标识某个子网的所有 IP 接口,多播地址标识一组 IP 接口。单播和广播是寻址方案的两个极端(要么单个要么全部),多播则意在两者之间提供一种折中方案。多播数据报只应该由对它感兴趣的接口接收,也就是说由运行相应多播会话应用系统的主机上的接口接收。另外,广播一般局限于局域网内使用,而多播则既可以用于局域网,也可以跨广域网使用

  • a.组播既可以用于局域网,也可以用于广域网
  • b.客户端需要加入多播组,才能接收到多播的数据

image-20230218103641613

组播地址
IP 多播通信必须依赖于 IP 多播地址,在 IPv4 中它的范围从 224.0.0.0239.255.255.255
并被划分为局部链接多播地址预留多播地址管理权限多播地址三类:

image-20230218103721366

设置组播

int setsockopt(int sockfd, int level, int optname,const void *optval, socklen_t optlen);
    // 服务器设置多播的信息,外出接口
    - level : IPPROTO_IP
    - optname : IP_MULTICAST_IF
    - optval : struct in_addr
        
    // 客户端加入到多播组:
    - level : IPPROTO_IP
    - optname : IP_ADD_MEMBERSHIP
    - optval : struct ip_mreq
        
struct ip_mreq{
    /* IP multicast address of group. */
    struct in_addr imr_multiaddr; // 组播的IP地址
    /* Local IP address of interface. */
    struct in_addr imr_interface; // 本地的IP地址
};

typedef uint32_t in_addr_t;
struct in_addr{
    in_addr_t s_addr;
};
//客户端
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>

int main() {

    // 1.创建一个通信的socket
    int fd = socket(PF_INET, SOCK_DGRAM, 0);
    if(fd == -1) {  perror("socket"); exit(-1); }   

    struct in_addr in;
    // 2.客户端绑定本地的IP和端口
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(9999);
    addr.sin_addr.s_addr = INADDR_ANY;

    int ret = bind(fd, (struct sockaddr *)&addr, sizeof(addr));
    if(ret == -1) {   perror("bind");  exit(-1); }

    struct ip_mreq op;
    inet_pton(AF_INET, "239.0.0.10", &op.imr_multiaddr.s_addr);
    op.imr_interface.s_addr = INADDR_ANY;

    // 加入到多播组
    setsockopt(fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &op, sizeof(op));

    // 3.通信
    while(1) {
        char buf[128];
        // 接收数据
        int num = recvfrom(fd, buf, sizeof(buf), 0, NULL, NULL);
        printf("server say : %s\n", buf);
    }

    close(fd);
    return 0;
}
//服务器
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>

int main() {

    // 1.创建一个通信的socket
    int fd = socket(PF_INET, SOCK_DGRAM, 0);
    if(fd == -1) {
        perror("socket");
        exit(-1);
    }   

    // 2.设置多播的属性,设置外出接口
    struct in_addr imr_multiaddr;
    // 初始化多播地址
    inet_pton(AF_INET, "239.0.0.10", &imr_multiaddr.s_addr);
    setsockopt(fd, IPPROTO_IP, IP_MULTICAST_IF, &imr_multiaddr, sizeof(imr_multiaddr));
    
    // 3.初始化客户端的地址信息
    struct sockaddr_in cliaddr;
    cliaddr.sin_family = AF_INET;
    cliaddr.sin_port = htons(9999);
    inet_pton(AF_INET, "239.0.0.10", &cliaddr.sin_addr.s_addr);

    // 3.通信
    int num = 0;
    while(1) {
       
        char sendBuf[128];
        sprintf(sendBuf, "hello, client....%d\n", num++);
        // 发送数据
        sendto(fd, sendBuf, strlen(sendBuf) + 1, 0, (struct sockaddr *)&cliaddr, sizeof(cliaddr));
        printf("组播的数据:%s\n", sendBuf);
        sleep(1);
    }

    close(fd);
    return 0;
}

04 / 本地套接字

本地套接字的作用:本地的进程间通信sockaddr_un
有关系的进程间的通信
没有关系的进程间的通信
本地套接字实现流程和网络套接字类似,一般呢采用TCP的通信流程。

image-20230218103847474

// 本地套接字通信的流程 - 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(lfd, addr, len);

3. 连接服务器
	struct sockaddr_un serveraddr;
	connect(fd, &serveraddr, sizeof(serveraddr));

4. 通信
    接收数据:read/recv
    发送数据:write/send
    
5. 关闭连接
    close();
// 头文件: sys/un.h
#define UNIX_PATH_MAX 108
struct sockaddr_un {
    sa_family_t sun_family; // 地址族协议 af_local
    char sun_path[UNIX_PATH_MAX]; // 套接字文件的路径, 这是一个伪文件, 大小永远=0
};
//客户端
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <sys/un.h>

int main() {
    unlink("client.sock");

    // 1.创建套接字
    int cfd = socket(AF_LOCAL, SOCK_STREAM, 0);
    if(cfd == -1) { perror("socket");  exit(-1); }

    // 2.绑定本地套接字文件
    struct sockaddr_un addr;
    addr.sun_family = AF_LOCAL;
    strcpy(addr.sun_path, "client.sock");
    int ret = bind(cfd, (struct sockaddr *)&addr, sizeof(addr));
    if(ret == -1) {
        perror("bind");
        exit(-1);
    }

    // 3.连接服务器
    struct sockaddr_un seraddr;
    seraddr.sun_family = AF_LOCAL;
    strcpy(seraddr.sun_path, "server.sock");
    ret = connect(cfd, (struct sockaddr *)&seraddr, sizeof(seraddr));
    if(ret == -1) {
        perror("connect");
        exit(-1);
    }

    // 4.通信
    int num = 0;
    while(1) {

        // 发送数据
        char buf[128];
        sprintf(buf, "hello, i am client %d\n", num++);
        send(cfd, buf, strlen(buf) + 1, 0);
        printf("client say : %s\n", buf);

        // 接收数据
        int len = recv(cfd, buf, sizeof(buf), 0);
        if(len == -1) {
            perror("recv");
            exit(-1);
        } else if(len == 0) {
            printf("server closed....\n");
            break;
        } else if(len > 0) {
            printf("server say : %s\n", buf);
        }
        sleep(1);
    }

    close(cfd);
    return 0;
}
//服务端
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <sys/un.h>

int main() {
    unlink("server.sock");

    // 1.创建监听的套接字
    int lfd = socket(AF_LOCAL, SOCK_STREAM, 0);
    if(lfd == -1) {   perror("socket");  exit(-1);  }

    // 2.绑定本地套接字文件
    struct sockaddr_un addr;
    addr.sun_family = AF_LOCAL;
    strcpy(addr.sun_path, "server.sock");
    int ret = bind(lfd, (struct sockaddr *)&addr, sizeof(addr));
    if(ret == -1) { perror("bind") exit(-1); }

    // 3.监听
    ret = listen(lfd, 100);
    if(ret == -1) {   perror("listen");  exit(-1);    }

    // 4.等待客户端连接
    struct sockaddr_un cliaddr;
    int len = sizeof(cliaddr);
    int cfd = accept(lfd, (struct sockaddr *)&cliaddr, &len);
    if(cfd == -1) {  perror("accept");  exit(-1); }

    printf("client socket filename: %s\n", cliaddr.sun_path);

    // 5.通信
    while(1) {
        char buf[128];
        int len = recv(cfd, buf, sizeof(buf), 0);

        if(len == -1) {
            perror("recv");
            exit(-1);
        } else if(len == 0) {
            printf("client closed....\n");
            break;
        } else if(len > 0) {
            printf("client say : %s\n", buf);
            send(cfd, buf, len, 0);
        }
    }

    close(cfd);
    close(lfd);
    return 0;
}

4.8 I/O多路复用(I/O多路转接)

(以内存的角度。文件到内存中就是输入)

I/O 多路复用使得程序能同时监听多个文件描述符,能够提高程序的性能,Linux 下实现 I/O 多路复用的系统调用主要有 select、poll 和 epoll。

image-20230220090355107

  • 上面的图表示单进程单线程,read()函数会一直阻塞,这个时候进程会是阻塞态,CPU执行权让给其他进程了。
  • 下面的图表示多进程或多线程,read()函数会一直阻塞,这个时候进程会是阻塞态,CPU执行权让给其他进程或者线程了(比前面的CPU利用率高)。

阻塞IO的模型(BIO)

image-20230220091057777

非阻塞,忙轮训

image-20230220091215461

非阻塞IO模型(NIO)

image-20230220091623117

有多少的客户端就要调用多少次read函数。

IO多路复用

image-20230220092023143

image-20230220092107757

以下的实验全用这个客户端程序

#include <stdio.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

int main() {

    // 创建socket
    int fd = socket(PF_INET, SOCK_STREAM, 0);
    if(fd == -1) { perror("socket"); return -1;}

    struct sockaddr_in seraddr;
    inet_pton(AF_INET, "192.168.73.101", &seraddr.sin_addr.s_addr);
    seraddr.sin_family = AF_INET;
    seraddr.sin_port = htons(9090);

    // 连接服务器
    int ret = connect(fd, (struct sockaddr *)&seraddr, sizeof(seraddr));

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

    int num = 0;
    while(1) {
        char sendBuf[1024] = {0};
        sprintf(sendBuf, "send data %d", num++);
        write(fd, sendBuf, strlen(sendBuf) + 1);

        // 接收
        int len = read(fd, sendBuf, sizeof(sendBuf));
        if(len == -1) {
            perror("read");
            return -1;
        }else if(len > 0) {
            printf("read buf = %s\n", sendBuf);
        } else {
            printf("服务器已经断开连接...\n");
            break;
        }
        sleep(1);
        // usleep(1000);
    }

    close(fd);

    return 0;
}

01 / select

image-20230220092549154

把fd的集合拷贝到内核态,内核会从3号位置遍历到最大的fd位置,标志为1的就检测有没有数据可读。有数据的fd置为1,没数据的置为0 。

image-20230220092931909

再拷贝给用户态,用户还得在遍历标志的最大的fd,就知道那些fd有数据可读,在这里3,4可读。如果100断开连接就清除100对应的标志位,如果没断开就继续监测

如果客户>1021就处理不了了。

主旨思想

  1. 首先要构造一个关于文件描述符的列表,将要监听的文件描述符添加到该列表中。
  2. 调用一个系统函数,监听该列表中的文件描述符,直到这些描述符中的一个或者多个进行I/O操作时,该函数才返回。
  • a.这个函数是阻塞
  • b.函数对文件描述符的检测的操作是由内核完成的
  1. 在返回时,它会告诉进程有多少(哪些)描述符要进行I/O操作。
// sizeof(fd_set) = 128 1024
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
    - 参数:
        - nfds : 委托内核检测的最大文件描述符的值 + 1
        - readfds : 要检测的文件描述符的读的集合,委托内核检测哪些文件描述符的读的属性
            - 一般检测读操作
            - 对应的是对方发送过来的数据,因为读是被动的接收数据,检测的就是读缓冲区
            - 是一个传入传出参数
        - writefds : 要检测的文件描述符的写的集合,委托内核检测哪些文件描述符的写的属性
        	- 委托内核检测写缓冲区是不是还可以写数据(写缓冲区不满的就可以写)
        - exceptfds : 检测发生异常的文件描述符的集合
        - timeout : 设置的超时时间
            struct timeval {
                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个文件描述符发生了变化
                
// 将参数文件描述符fd对应的标志位设置为0
void FD_CLR(int fd, fd_set *set);
// 判断fd对应的标志位是0还是1, 返回值 : fd对应的标志位的值,0返回0, 1返回1
int FD_ISSET(int fd, fd_set *set);
// 将参数文件描述符fd 对应的标志位,设置为1
void FD_SET(int fd, fd_set *set);
// fd_set一共有1024 bit, 全部初始化为0
void FD_ZERO(fd_set *set);

文字描述符只有1024个(可以自己分配),会受限系统设置的文字描述符大小。假设设置连接数上限为3个第4个连接会有检测到,但是不会分配资源,基本就丢弃了。超出数组大小设置,会被抛弃。

image-20230220093656268

一个字节占8个标志位。int 占4个字节,long int占8个字节,这里的类型是long int型的数组,数组容量是1024/64=16个,16×8=128个字节,128字节×8位/字节=1024位。

// select服务器
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/select.h>

int main() {
    char serverIP[] = {"192.168.73.100"};
    unsigned short serverPort = 9090;

    // 1.创建socket连接
    int lfd = socket(AF_INET,SOCK_STREAM,0);
    if(lfd == -1){ perror("socket");  exit(-1); }

    // 2.封装端口和IP
    struct sockaddr_in serverInfo;
    serverInfo.sin_family = AF_INET;
    // serverInfo.sin_addr.s_addr = INADDR_ANY;  这两种方式都可
    inet_pton(AF_INET,serverIP,&serverInfo.sin_addr.s_addr);
    serverInfo.sin_port = htons(serverPort);

    // 3.设置端口复用
    short optval = 1;
    setsockopt(lfd,SOL_SOCKET,SO_REUSEADDR,&optval,sizeof(optval));

    // 4.绑定文件描述符,端口和IP
    int ret = bind(lfd,(struct sockaddr*)&serverInfo,sizeof(serverInfo));
    if(ret==-1){ perror("bind");  exit(-1); }

    // 5.listen监听
    ret = listen(lfd,10);
    if(ret==-1){ perror("bind");  exit(-1); }

    // 创建一个fd_set的集合,存放的是需要检测的文件描述符,内核好像
    fd_set rdset,tmp;  //可以表示1024个文件描述符
    FD_ZERO(&rdset);
    FD_SET(lfd,&rdset);
    int maxfd = lfd;

    // 6. while  accept接收连接
    while(1){
        tmp = rdset;
        // 调用select系统函数,让内核帮检测哪些文件描述符有数据
        ret = select(maxfd+1,&tmp,NULL,NULL,NULL); //最后的NULL表示永久阻塞,tmp作为一个传入参数,交给内核去修改,有数据的就置为1,没数据就置为0
        if(ret == -1){
            perror("select");
            exit(-1);
        }else if(ret ==0){
            continue;
        }else if(ret > 0){
            // 说明检测到了有文件描述符的对应的缓冲区的数据发生了改变
            if(FD_ISSET(lfd,&tmp)){
                // 表示有新的客户端连接进来了
                struct sockaddr_in clientInfo;
                socklen_t len = sizeof(clientInfo);
                int cfd=accept(lfd,(struct sockaddr*)&clientInfo,&len);//阻塞函数
                printf("连接的文件描述符为: %d  ,通信的文件描述符为: %d\n",lfd,cfd);

                // 将新的文件描述符加入到集合中
                FD_SET(cfd,&rdset);

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

            for(int i = lfd+1;i <= maxfd;i++){ //遍历所有的通信文件描述符
                if(FD_ISSET(i,&tmp)){
                    // 说明这个文件描述符对应的客户端发来了数据
                    char recvBuf[1024] = {0};
                    int len = read(i,recvBuf,sizeof(recvBuf));
                    if(len == -1){
                        perror("read");
                        close(i);
                        continue;
                    }else if(len == 0){
                        printf("client close  \n");
                        close(i);
                        FD_CLR(i,&rdset);//回收资源,可以重用之前的文件描述符
                    }else if(len > 0){
                        printf("server receive data: %s\n",recvBuf);
                        write(i,recvBuf,strlen(recvBuf)+1);
                    }
                }
            }
        }
    }
    //     7.封装客户端信息
    //     8.通信
    close(lfd);
    return 0;
}

image-20230220094510651

缺点:

  • 1.每次调用 select ,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大
  • 2.同时每次调用 select 都需要在内核遍历传递进来的所有 fd ,这个开销在 fd 很多时也很大
  • 3.把fd的集合拷贝到内核态,内核会从3号位置遍历到最大的fd位置,标志为1的就检测有没有数据可读。有数据的fd置为1,没数据的置为0 。(内核需要遍历所有的,传给用户也要遍历到所有fd的最大值。)
  • 3.select支持的文件描述符数量太小了,默认是 1024
  • 4.fds集合不能重用,每次都需要重置

02 / poll

比select的改进点:

  • 1.传入需要检测的struct pollfd *fds可以重用,就是内核不会改这个结构体数组。
  • 2.poll支持的文件描述符没有1024的限制。

缺点

  • 1.每次调用 poll,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大
  • 2.同时每次调用 poll都需要在内核遍历传递进来的所有 fd ,这个开销在 fd 很多时也很大
  • 把fd的集合拷贝到内核态,内核会从0号位置遍历到最大的fd位置,不修改传入的检测时间,把检测的结果放到传出事件 。(内核需要遍历所有的,传给用户也要遍历到所有fd的最大值。)
#include <poll.h>
struct pollfd {
    int fd; 		/* 委托内核检测的文件描述符 */
    short events; 	/* 委托内核检测文件描述符的什么事件 */
    short revents; 	/* 文件描述符实际发生的事件 */
};

struct pollfd myfd;
myfd.fd = 5;
myfd.events = POLLIN | POLLOUT;

int poll(struct pollfd *fds, nfds_t nfds, int timeout);	//
    - 参数:
        - fds : 是一个struct pollfd 结构体数组,这是一个需要检测的文件描述符的集合
        - nfds : 这个是第一个参数数组中最后一个有效元素的下标 + 1
        - timeout : 阻塞时长
             0 : 不阻塞
            -1 : 阻塞,当检测到需要检测的文件描述符有变化,解除阻塞
            >0 : 阻塞的时长
        - 返回值:
            -1 : 失败
            >0(n) : 成功,n表示检测到集合中有n个文件描述符发生变化

image-20230219103634167

#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <poll.h>
const unsigned short maxLink = 3;  //设置最大连接数

int main() {
    char serverIP[] = {"192.168.73.101"};
    unsigned short serverPort = 9090;

    // 1.创建socket
    int lfd = socket(AF_INET,SOCK_STREAM,0);
    if(lfd == -1){ perror("socket");  exit(-1);}

    // 2.封装自己的IP和端口
    struct sockaddr_in serverInfo;
    serverInfo.sin_family = AF_INET;
    serverInfo.sin_addr.s_addr = INADDR_ANY; 
    // inet_pton(AF_INET,serverIP,&serverInfo.sin_addr.s_addr);
    serverInfo.sin_port = htons(serverPort);

    // 3.端口复用
    int optval = 1;
    setsockopt(AF_INET,SOL_SOCKET,SO_REUSEADDR,&optval,sizeof(optval));

    // 4.绑定端口和IP,文件描述符
    int ret = bind(lfd,(struct sockaddr*)&serverInfo,sizeof(serverInfo));
    if(ret == -1){ perror("bind");  exit(-1);}

    // 5.listen监听端口
    ret = listen(lfd,maxLink);
    if(ret == -1){ perror("bind");  exit(-1);}

    //设置检测的文件描述符数组并初始化
    struct pollfd fds[maxLink+1];
    for(int i = 0;i < maxLink+1; i++){
        fds[i].fd = -1;		//表示没用
        fds[i].events = POLLIN;
    }
    fds[0].fd = lfd;        //需要检测的文件描述符
    int maxDetections = 0;  //最大检测数

    // 开始死循环
    while(1){
        // 6.poll把需要检测的文件描述符交给内核
        ret = poll(fds, maxDetections + 1, -1);
        if(ret == -1){ 
            perror("poll");  
            exit(-1);
        }else if(ret == 0){ //没有检测到连接请求
            continue;
        }else if(ret > 0){  // 说明检测到了有文件描述符的对应的缓冲区的数据发生了改变
            if(fds[0].revents & POLLIN){

                // 表示有新的客户端连接进来了
                struct sockaddr_in cliaddr;
                socklen_t len = sizeof(cliaddr);
                int cfd = accept(lfd, (struct sockaddr *)&cliaddr, &len);

                //输出客户端信息
                char clientIP[16]={0};
                inet_ntop(AF_INET,&cliaddr.sin_addr.s_addr,clientIP,sizeof(clientIP));
                printf("客户端IP:%s,端口为:%d。使用的文件描述符为:%d\n",clientIP,ntohs(cliaddr.sin_port),cfd);

                // 遍历所有的通信文件描述符找一个可以用的,将新的文件描述符加入到集合中
                for(int i= 1;i<maxLink+1;i++){
                    if(fds[i].fd == -1){
                        fds[i].fd = cfd;
                        fds[i].events = POLLIN;
                        break;
                    }
                }

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

            for(int i = 1; i<maxDetections;i++){
                if(fds[i].fd != -1 && fds[i].revents & POLLIN){
                    // 说明这个文件描述符对应的客户端发来了数据
                    char buf[1024]={0};
                    int len = read(fds[i].fd,buf,sizeof(buf));
                    if(len == -1){
                        perror("read");
                        close(fds[i].fd);
                        exit(-1);
                    }else if(len ==0){
                        printf("client closed...\n");
                        close(fds[i].fd);
                        fds[i].fd = -1;
                    }else if(len > 0){
                        printf("server receive data:%s \n",buf);
                        write(fds[i].fd, buf, strlen(buf) + 1);
                    }
                }
            }  
        }
    }
    close(lfd);
    return 0;
}

03 / epoll

image-20230220102333902

#include <sys/epoll.h>
// 创建一个新的epoll实例。在内核中创建了一个数据,这个数据中有两个比较重要的数据,一个是需要检测的文件描述符的信息(红黑树),还有一个是就绪列表,存放检测到数据发送改变的文件描述符信息(双向链表)。
int epoll_create(int size);
    - 参数:
    size : 目前没有意义了。为了兼容之前的版本。现在随便写一个数,必须大于0
    - 返回值:
        -1 : 失败
        > 0 : 文件描述符,操作epoll实例的
            
typedef union epoll_data {	//联合体,只有一个才有用
    void *ptr;		//如果搞回调函数也会用这个
    int fd;			//主要用这个吧
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

struct epoll_event {
    uint32_t events; 	/* Epoll events */
    epoll_data_t data; 	/* User data variable */  //用户的数据信息
};
    events描述事件类型,其中epoll事件类型有以下几种
        EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭)
        EPOLLOUT:表示对应的文件描述符可以写
        EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)
        EPOLLERR:表示对应的文件描述符发生错误
        EPOLLHUP:表示对应的文件描述符被挂断;
        EPOLLET:将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)而言的
        EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
    
// 对epoll实例进行管理:添加文件描述符信息,删除信息,修改信息
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    - 参数:
    - epfd : epoll实例对应的文件描述符(就是这个epoll_create()的返回值)
    - op : 要进行什么操作		(就是对底层的红黑树进行修改)
        EPOLL_CTL_ADD: 添加
        EPOLL_CTL_MOD: 修改
        EPOLL_CTL_DEL: 删除
    - fd : 要检测的文件描述符
    - event : 检测文件描述符什么事情
        
// 检测函数
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
    - 参数:
    - epfd : epoll实例对应的文件描述符
    - events : 传出参数,保存了发送了变化的文件描述符的信息(返回的那个双向链表,一般用数组保存)
    - maxevents : 第二个参数结构体数组的大小
    - timeout : 阻塞时间
        -   0 : 不阻塞
        -  -1 : 阻塞,直到检测到fd数据发生变化,解除阻塞
        -  >0 : 阻塞的时长(毫秒)
    - 返回值:
        - 成功,返回发送变化的文件描述符的个数 > 0
        - 失败 -1
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>

int main() {

    // 创建socket
    int lfd = socket(PF_INET, SOCK_STREAM, 0);
    struct sockaddr_in saddr;
    saddr.sin_port = htons(9090);
    saddr.sin_family = AF_INET;
    saddr.sin_addr.s_addr = INADDR_ANY;

    // 绑定
    bind(lfd, (struct sockaddr *)&saddr, sizeof(saddr));

    // 监听
    listen(lfd, 8);

    // 调用epoll_create()在内核中创建一个epoll实例结构
    int epfd = epoll_create(100); //100可以是非0的任意值,已废弃。

    // 将监听的文件描述符相关的检测信息添加到epoll实例中
    struct epoll_event epEvent;
    epEvent.events=EPOLLIN;
    epEvent.data.fd = lfd;  //用户的数据信息
    //把一个epoll_event结构放入到内核中evevtpoll的红黑树中,就是需要检测的
    epoll_ctl(epfd,EPOLL_CTL_ADD,lfd,&epEvent);

    struct epoll_event epEvents[1024]; //建立这样的数组接收内核给我们返回的变化的事件
    while(1){
        int num = epoll_wait(epfd,epEvents,sizeof(epEvents),-1);
        if(num == -1){
            perror("epoll_wait");
            exit(-1);
        }
        printf("有几个文件描述符发生改变:num = %d\n", num); //有几个文件描述符发生改变

        for(int i = 0; i < num; i++){
            int curfd = epEvents[i].data.fd ;
            if(curfd == lfd){
                //有客户请求建立连接
                struct sockaddr_in clientInfo;
                socklen_t len = sizeof(clientInfo);
                int cfd = accept(lfd,(struct sockaddr*)&clientInfo,&len);
                
                char clientIP[16];
                inet_ntop(AF_INET,&clientInfo.sin_addr.s_addr,clientIP,sizeof(clientIP));
                printf("客户的IP:%s,端口:%d,使用的文件描述符:%d\n",clientIP,ntohs(clientInfo.sin_port),cfd);

                epEvent.events = EPOLLIN; //重用之前的epEvent  也可能epev.events = EPOLLIN|EPOLLOUT;
                epEvent.data.fd = cfd;
                epoll_ctl(epfd,EPOLL_CTL_ADD,cfd,&epEvent);
            }else {
                if(epEvents[i].events & EPOLLOUT) {
                    continue;
                }
                //有数据到
                char buf[1024]={0};
                int len = read(curfd,buf,sizeof(buf));
                if(len==-1){
                    perror("read");
                    exit(-1);
                }else if(len==0){
                    printf("client close\n");
                    epoll_ctl(epfd,EPOLL_CTL_DEL,curfd,NULL);
                    close(curfd);
                }else if(len>0){
                    printf("server receivedata: %s\n",buf);
                    write(curfd,buf,strlen(buf)+1);
                }
            }
        }
    }
    close(epfd);
    close(lfd);
    return 0;
}

04 / Epoll 的工作模式:

建议listenfd用LT模式,防止漏掉客户

  • LT 模式 (水平触发)
    假设委托内核检测读事件 -> 检测fd的读缓冲区
    读缓冲区有数据 - > epoll检测到了会给用户通知
    • a.用户不读数据,数据一直在缓冲区,epoll 会一直通知
    • b.用户只读了一部分数据,epoll会通知
    • c.缓冲区的数据读完了,不通知
  • LT(level - triggered)是缺省的工作方式,并且同时支持 block 和 no-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的 fd 进行 IO 操作。如果你不作任何操作,内核还是会继续通知你的。
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>

int main() {

    // 创建socket
    int lfd = socket(PF_INET, SOCK_STREAM, 0);
    struct sockaddr_in saddr;
    saddr.sin_port = htons(9090);
    saddr.sin_family = AF_INET;
    saddr.sin_addr.s_addr = INADDR_ANY;

    // 绑定
    bind(lfd, (struct sockaddr *)&saddr, sizeof(saddr));

    // 监听
    listen(lfd, 8);

    // 调用epoll_create()创建一个epoll实例
    int epfd = epoll_create(100);

    // 将监听的文件描述符相关的检测信息添加到epoll实例中
    struct epoll_event epev;
    epev.data.fd = lfd;
    epev.events = EPOLLIN;
    epoll_ctl(epfd,EPOLL_CTL_ADD,lfd,&epev);

    struct epoll_event epevs[1024];

    while(1) {

        int ret = epoll_wait(epfd, epevs, sizeof(epevs), -1);
        if(ret == -1) {
            perror("epoll_wait");
            exit(-1);
        }

        printf("ret = %d\n", ret);

        for(int i = 0; i < ret; i++) {

            int curfd = epevs[i].data.fd;

            if(curfd == lfd) {
                // 监听的文件描述符有数据达到,有客户端连接
                struct sockaddr_in cliaddr;
                int len = sizeof(cliaddr);
                int cfd = accept(lfd, (struct sockaddr *)&cliaddr, &len);

                epev.events = EPOLLIN;  //默认水平模式 需要调整的话 |EPOLLET
                epev.data.fd = cfd;
                epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &epev);
            } else {
                if(epevs[i].events & EPOLLOUT) {
                    continue;
                }   
                // 有数据到达,需要通信
                char buf[5] = {0};
                int len = read(curfd, buf, sizeof(buf));
                if(len == -1) {
                    perror("read");
                    exit(-1);
                } else if(len == 0) {
                    printf("client closed...\n");
                    epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL);
                    close(curfd);
                } else if(len > 0) {
                    printf("read buf = %s\n", buf);
                    write(curfd, buf, strlen(buf) + 1);
                }
            }

        }
    }

    close(lfd);
    close(epfd);
    return 0;
}
  • ET 模式(边沿触发)
    假设委托内核检测读事件 -> 检测fd的读缓冲区
    读缓冲区有数据 - > epoll检测到了会给用户通知
    • a.用户不读数据,数据一致在缓冲区中,epoll下次检测的时候就不通知了
    • b.用户只读了一部分数据,epoll不通知
    • c.缓冲区的数据读完了,不通知
  • ET(edge - triggered)是高速工作方式,只支持 no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了。但是请注意,如果一直不对这个 fd 作 IO 操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)。(基本read也可以设置为非阻塞,循环去读数据
  • ET 模式在很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。epoll工作在 ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。(客户每写一次数据他就通知一次,不会管你有没有读完数据
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <errno.h>

int main() {

    // 创建socket
    int lfd = socket(PF_INET, SOCK_STREAM, 0);
    struct sockaddr_in saddr;
    saddr.sin_port = htons(9090);
    saddr.sin_family = AF_INET;
    saddr.sin_addr.s_addr = INADDR_ANY;

    // 绑定
    bind(lfd, (struct sockaddr *)&saddr, sizeof(saddr));

    // 监听
    listen(lfd, 8);

    // 调用epoll_create()创建一个epoll实例
    int epfd = epoll_create(100);

    // 将监听的文件描述符相关的检测信息添加到epoll实例中
    struct epoll_event epev;
    epev.events = EPOLLIN;
    epev.data.fd = lfd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &epev);

    struct epoll_event epevs[1024];

    while(1) {

        int ret = epoll_wait(epfd, epevs, 1024, -1);
        if(ret == -1) {  perror("epoll_wait");  exit(-1); }
        printf("监测到发生变化的事件数目:ret = %d\n", ret);

        for(int i = 0; i < ret; i++) {
            int curfd = epevs[i].data.fd;
            if(curfd == lfd) {
                // 监听的文件描述符有数据达到,有客户端连接
                struct sockaddr_in cliaddr;
                int len = sizeof(cliaddr);
                int cfd = accept(lfd, (struct sockaddr *)&cliaddr, &len);

                // 设置cfd属性非阻塞
                int flag = fcntl(cfd, F_GETFL);
                flag |= O_NONBLOCK;
                fcntl(cfd, F_SETFL, flag);

                epev.events = EPOLLIN | EPOLLET;    // 设置边沿触发
                epev.data.fd = cfd;
                epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &epev);
            } else {
                if(epevs[i].events & EPOLLOUT) {
                    continue;
                }  

                // 循环读取出所有数据
                char buf[5];
                int len = 0;
                while( (len = read(curfd, buf, sizeof(buf))) > 0) {
                    // 打印数据
                    // printf("recv data : %s\n", buf);
                    write(STDOUT_FILENO, buf, len);
                    write(curfd, buf, len);
                }
                if(len == 0) {
                    printf("client closed....");
                }else if(len == -1) {
                    if(errno == EAGAIN) {
                        printf("data over.....");
                    }else {
                        perror("read");
                        exit(-1);
                    }    
                }
            }
        }
    }
    close(lfd);
    close(epfd);
    return 0;
}

epoll反应堆模型(libevent核心思想)

libevent——支持跨平台,用了大量的回调函数

第五章 项目实战与总结

5.1 阻塞/非阻塞、同步/异步(网络IO)

ssize_t recv(int cfd, void *buf, size_t len, int flags)
    cfd:指定接收端套接字描述符;
    buf:指向一个缓冲区,该缓冲区用来存放recv函数接收到的数据;
    len:指明buf的长度;
    flags:一般置为0;
    返回值:
    	失败时,返回值<0;(返回-1时,信号为EINTR——(阻塞)被中断,EAGAIN——(非阻塞)再来一次,EWOULDBLOCK)
        超时或对端主动关闭,返回值=0;
        成功时,返回值是返回接收数据的长度>0。
ssize_t send(int socket, const void *buf, size_t len, int flags);  
    参数一:指定发送端套接字描述符;
    参数二:指明一个存放应用程序要发送数据的缓冲区;
    参数三:指明实际要发送的数据的字节数;
    参数四:一般置0;
    返回值:
        失败时,返回值小于0;
        超时或对端主动关闭,返回值等于0;
        成功时,返回值是返回发送数据的长度。

数据就绪:根据系统IO操作的就绪状态

  • 阻塞:上面代码的cfd默认是阻塞的,也可以用fctl()设置为非阻塞。
  • 非阻塞

数据读写:根据应用程序和内核的交互方式

  • 同步:就是接受数据时我们自己把内核区的数据搬到buf中,搬运期间不能去干别的活,花应用程序的时间。
  • 异步:告诉系统(系统会把TCP接收缓冲区的数据——>buf),应用程序可以干别的活。异步一般和非阻塞使用。接口:aio_read(),aio_write().

陈硕:在处理 IO 的时候,阻塞和非阻塞都是同步 IO,只有使用了特殊的 API 才是异步 IO。

image-20230220105340506

一个典型的网络IO接口调用,分为两个阶段,分别是“数据就绪” 和 “数据读写”,数据就绪阶段分为阻塞和非阻塞,表现得结果就是,阻塞当前线程或是直接返回。

  • 同步表示A向B请求调用一个网络IO接口时(或者调用某个业务逻辑API接口时),数据的读写都是由请求方A自己来完成的(不管是阻塞还是非阻塞);
  • 异步表示A向B(系统)请求调用一个网络IO接口时(或者调用某个业务逻辑API接口时),向B传入请求的事件以及事件发生时通知的方式,A就可以处理其它逻辑了,当B监听到事件处理完成后,会用事先约定好的通知方式通知A处理结果

注意:阻塞I/O,非阻塞I/O,信号驱动I/O和I/O复用都是同步I/O。同步I/O指内核向应用程序通知的是就绪事件,比如只通知有客户端连接,要求用户代码自行执行I/O操作,异步I/O是指内核向应用程序通知的是完成事件,比如读取客户端的数据后才通知应用程序,由内核完成I/O操作。

5.2 Unix/Linux上的五种IO模型*

01 / 阻塞 blocking

调用者调用了某个函数,等待这个函数返回,期间什么也不做,不停的去检查这个函数有没有返回,必须等这个函数返回才能进行下一步动作。

image-20230221100944016

当用户进程调用了read 这个系统调用, kernel(内核) 就开始了 IO 的第一个阶段:准备数据。对于network IO 来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的数据包),这个时候 kernel 就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。当 kernel一直等到数据准备好了,它就会将数据从 kernel 中拷贝到用户内存,然后 kernel 返回结果,用户进程才解除 block 的状态,重新运行起来。

所以, blocking IO 的特点就是在 IO 执行的两个阶段(等待数据和拷贝数据两个阶段)都被block 了。

02 / 非阻塞 non-blocking(NIO)

非阻塞等待,每隔一段时间就去检测IO事件是否就绪。没有就绪就可以做其他事。非阻塞I/O执行系统调用总是立即返回,不管事件是否已经发生,若事件没有发生,则返回-1,此时可以根据 errno 区分这两种情况,**对于acceptrecvsend,事件未发生时,errno 通常被设置成 EAGAIN**还有EWOILDBLOCK

image-20230221101656388

从图中可以看出,当用户进程发出read 操作时,如果 kernel(内核) 中的数据还没有准备好,那么它并不会 block 用户进程,而是立刻返回一个 error 。从用户进程角度讲 ,它发起一个read 操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个 error时,它就知道数据还没有准备好,于是它可以再次发送 read 操作。一旦 kernel 中的数据准备好了,并且又再次收到了用户进程的 system call ,那么它马上就将数据拷贝到了用户内存,然后返回 所以,在非阻塞式 IO 中,用户进程其实是需要不断的主动询问 kernel数据准备好了没有

在非阻塞状态下, recv() 接口在被调用后立即返回,返回值代表了不同的含义。如在本例中,

  • recv()返回值大于 0 ,表示接受数据完毕,返回值即是接受到的字节数
  • recv()返回 0 ,表示连接已经正常断开
  • recv() 返回 -1 ,且 errno 等于 EAGAIN ,表示 recv 操作还没执行完成;
  • recv()返回 -1 ,且 errno 不等于 EAGAIN ,表示 recv 操作遇到系统错误 errno 。非阻塞的接口相比于阻塞型接口的显著差异在于,在被调用之后立即返回。使用如下的函数可以将某句柄 fd 设为非阻塞状态。fcntl ( fd, F_SETFL, O_NONBLOCK);

03 / 多路复用IO(IO multiplexing)

Linux 用 select/poll/epoll 函数实现 IO 复用模型,这些函数也会使进程阻塞,但是和阻塞IO所不同的是这些函数可以同时阻塞多个IO操作。而且可以同时对多个读操作、写操作的IO函数进行检测。直到有数据可读或可写时,才真正调用IO操作函数。

image-20230221102242999

当用户进程调用了select ,那么整个进程会被 block ,而同时 kernel 会 监视 所有 select 负责的 socket ,当任何一个 socket 中的数据准备好了, select 就会返回。这个时候用户进程再调用 read 操作,将数据从 kernel 拷贝到用户进程。

04 / 信号驱动(signal-driven)用的较少

Linux 用套接口进行信号驱动 IO,安装一个信号处理函数,进程继续运行并不阻塞,当IO事件就绪,进程收到SIGIO 信号,然后处理 IO 事件。

image-20230221102857155

内核在第一个阶段是异步,在第二个阶段是同步;与非阻塞IO的区别在于它提供了消息通知机制,不需要用户进程不断的轮询检查,减少了系统API的调用次数,提高了效率。

05 / 异步(asynchronous)

Linux中,可以调用 aio_read 函数告诉内核描述字缓冲区指针和缓冲区的大小、文件偏移及通知的方式,然后立即返回,当内核将数据拷贝到缓冲区后,再通知应用程序

image-20230221103022367

/*Asynchronous I/O control block. */
struct aiocb
{
    int aio_fildes; /* File desriptor. */
    int aio_lio_opcode; /* Operation to be performed. */
    int aio_reqprio; /* Request priority offset. */
    volatile void *aio_buf; /* Location of buffer. */
    size_t aio_nbytes; /* Length of transfer. */
    struct sigevent aio_sigevent; /* Signal number and value. */
    
    /* Internal members. */
    struct aiocb *__next_prio;
    int __abs_prio;
    int __policy;
    int __error_code;
    __ssize_t __return_value;
    
    #ifndef __USE_FILE_OFFSET64
    __off_t aio_offset; /* File offset. */
    char __pad[sizeof (__off64_t) - sizeof (__off_t)];
    #else
    __off64_t aio_offset; /* File offset. */
    #endif
    
    char __glibc_reserved[32];
};

5.3 Web Server(网页服务器)

一个 Web Server 就是一个服务器软件(程序),或者是运行这个服务器软件的硬件(计算机)。其主要功能是通过 HTTP 协议与客户端(通常是浏览器(Browser))进行通信,来接收,存储,处理来自客户端的 HTTP 请求,并对其请求做出 HTTP 响应,返回给客户端其请求的内容(文件、网页等)或返回一个 Error 信息。

image-20230221103501833

通常用户使用 Web 浏览器与相应服务器进行通信。在浏览器中键入“域名”或“IP地址:端口号”,浏览器则先将你的域名解析成相应的 IP 地址或者直接根据你的IP地址向对应的 Web 服务器发送一个 HTTP 请求。这一过程首先要通过 TCP 协议的三次握手建立与目标 Web 服务器的连接,然后 HTTP 协议生成针对目标 Web 服务器的 HTTP 请求报文,通过 TCP、IP 等协议发送到目标 Web 服务器上。

5.4 HTTP协议(应用层的协议)

01 / 简介

超文本传输协议(Hypertext Transfer Protocol,HTTP)是一个简单的请求 - 响应协议,它通常运行在TCP 之上。它指定了客户端可能发送给服务器什么样的消息以及得到什么样的响应。请求和响应消息的头以 ASCII 形式给出;而消息内容则具有一个类似 MIME 的格式。HTTP是万维网的数据通信的基础。

HTTP的发展是由蒂姆·伯纳斯-李于1989年在欧洲核子研究组织(CERN)所发起。HTTP的标准制定由万维网协会(World Wide Web Consortium,W3C)和互联网工程任务组(Internet Engineering Task Force,IETF)进行协调,最终发布了一系列的RFC,其中最著名的是1999年6月公布的 RFC 2616,定义了HTTP协议中现今广泛使用的一个版本——HTTP 1.1。

02 / 概述

HTTP 是一个客户端终端(用户)和服务器端(网站)请求和应答的标准(TCP)。通过使用网页浏览器、网络爬虫或者其它的工具,客户端发起一个HTTP请求到服务器上指定端口(默认端口为80)。我们称这个客户端为用户代理程序(user agent)。应答的服务器上存储着一些资源,比如 HTML 文件和图像。我们称这个应答服务器为源服务器(origin server)。在用户代理和源服务器中间可能存在多个“中间层”,比如代理服务器、网关或者隧道(tunnel)。

尽管 TCP/IP 协议是互联网上最流行的应用,HTTP 协议中,并没有规定必须使用它或它支持的层。事实上,HTTP可以在任何互联网协议上,或其他网络上实现。HTTP 假定其下层协议提供可靠的传输。因此,任何能够提供这种保证的协议都可以被其使用。因此也就是其在 TCP/IP 协议族使用 TCP 作为其传输层。

通常,由HTTP客户端发起一个请求,创建一个到服务器指定端口(默认是80端口)的 TCP 连接。HTTP服务器则在那个端口监听客户端的请求。一旦收到请求,服务器会向客户端返回一个状态,比如”HTTP/1.1 200 OK”,以及返回的内容,如请求的文件、错误消息、或者其它信息。

03 / 工作原理

HTTP 协议定义 Web 客户端如何从 Web 服务器请求 Web 页面,以及服务器如何把 Web 页面传送给客户端。HTTP 协议采用了请求/响应模型。客户端向服务器发送一个请求报文,请求报文包含请求的方法、URL、协议版本、请求头部和请求数据。服务器以一个状态行作为响应,响应的内容包括协议的版本、成功或者错误代码、服务器信息、响应头部和响应数据。

以下是 HTTP 请求/响应的步骤

  1. 客户端连接到 Web 服务器
    一个HTTP客户端,通常是浏览器,与 Web 服务器的 HTTP 端口(默认为 80 )建立一个 TCP 套接字连接。例如,http://www.baidu.com。(URL)
  2. 发送 HTTP 请求
    通过 TCP 套接字,客户端向 Web 服务器发送一个文本的请求报文,一个请求报文由请求行、请求头部、空行和请求数据 4 部分组成。
  3. 服务器接受请求并返回 HTTP 响应
    Web 服务器解析请求,定位请求资源。服务器将资源复本写到 TCP 套接字,由客户端读取。一个响应由状态行、响应头部、空行和响应数据 4 部分组成。
  4. 释放连接 TCP 连接
    若 connection 模式为 close,则服务器主动关闭 TCP连接,客户端被动关闭连接,释放 TCP 连接;若connection 模式为 keepalive,则该连接会保持一段时间,在该时间内可以继续接收请求;
  5. 客户端浏览器解析 HTML 内容
    客户端浏览器首先解析状态行,查看表明请求是否成功的状态代码。然后解析每一个响应头,响应头告知以下为若干字节的 HTML 文档和文档的字符集。客户端浏览器读取响应数据 HTML,根据HTML 的语法对其进行格式化,并在浏览器窗口中显示

例如:在浏览器地址栏键入URL,按下回车之后会经历以下流程

  1. 浏览器向 DNS 服务器请求解析该 URL 中的域名所对应的 IP 地址;
  2. 解析出 IP 地址后,根据该 IP 地址和默认端口 80,和服务器建立 TCP 连接;
  3. 浏览器发出读取文件( URL 中域名后面部分对应的文件)的 HTTP 请求,该请求报文作为 TCP 三次握手的第三个报文的数据发送给服务器;
  4. 服务器对浏览器请求作出响应,并把对应的 HTML 文本发送给浏览器;
  5. 释放 TCP 连接;
  6. 浏览器将该 HTML 文本并显示内容。

image-20230221104449399

HTTP 协议是基于 TCP/IP 协议之上的应用层协议,基于 请求-响应 的模式。HTTP 协议规定,请求从客户端发出,最后服务器端响应该请求并返回。换句话说,肯定是先从客户端开始建立通信的,服务器端在没有接收到请求之前不会发送响应

04 / HTTP 请求报文格式

image-20230221104529586

GET / HTTP/1.1
Host: www.baidu.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,/;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Cookie: BAIDUID=6729CB682DADC2CF738F533E35162D98:FG=1;
BIDUPSID=6729CB682DADC2CFE015A8099199557E; PSTM=1614320692; BD_UPN=13314752;
BDORZ=FFFB88E999055A3F8A630C64834BD6D0;
__yjs_duid=1_d05d52b14af4a339210722080a668ec21614320694782; BD_HOME=1;
H_PS_PSSID=33514_33257_33273_31660_33570_26350;
BA_HECTOR=8h2001alag0lag85nk1g3hcm60q
Upgrade-Insecure-Requests: 1
Cache-Control: max-age=0

05 / HTTP响应报文格式

image-20230221104655457

HTTP/1.1 200 OK
Bdpagetype: 1
Bdqid: 0xf3c9743300024ee4
Cache-Control: private
Connection: keep-alive
Content-Encoding: gzip
Content-Type: text/html;charset=utf-8
Date: Fri, 26 Feb 2021 08:44:35 GMT
Expires: Fri, 26 Feb 2021 08:44:35 GMT
Server: BWS/1.1
Set-Cookie: BDSVRTM=13; path=/
Set-Cookie: BD_HOME=1; path=/
Set-Cookie: H_PS_PSSID=33514_33257_33273_31660_33570_26350; path=/; domain=.baidu.com
Strict-Transport-Security: max-age=172800
Traceid: 1614329075128412289017566699583927635684
X-Ua-Compatible: IE=Edge,chrome=1
Transfer-Encoding: chunked

06 / HTTP请求方法

HTTP/1.1 协议中共定义了八种方法(也叫“动作”)来以不同方式操作指定的资源:

  1. GET:向指定的资源发出“显示”请求。使用 GET 方法应该只用在读取数据,而不应当被用于产生“副作用”的操作中,例如在 Web Application 中。其中一个原因是 GET 可能会被网络蜘蛛等随意访问。
  2. HEAD:与 GET 方法一样,都是向服务器发出指定资源的请求。只不过服务器将不传回资源的本文部分。它的好处在于,使用这个方法可以在不必传输全部内容的情况下,就可以获取其中“关于该资源的信息”(元信息或称元数据)。
  3. POST向指定资源提交数据,请求服务器进行处理(例如提交表单或者上传文件)。数据被包含在请求本文中。这个请求可能会创建新的资源或修改现有资源,或二者皆有
  4. PUT向指定资源位置上传其最新内容
  5. DELETE:请求服务器删除 Request-URI 所标识的资源。
  6. TRACE:回显服务器收到的请求,主要用于测试或诊断。
  7. OPTIONS:这个方法可使服务器传回该资源所支持的所有 HTTP 请求方法。用’*’来代替资源名称,向 Web 服务器发送 OPTIONS 请求,可以测试服务器功能是否正常运作。
  8. CONNECT:HTTP/1.1 协议中预留给能够将连接改为管道方式的代理服务器。通常用于SSL加密服务器的链接(经由非加密的 HTTP 代理服务器)。

GET和POST的区别

  • 最直观的区别就是GET把参数包含在URL中,POST通过request body传递参数。
  • GET请求参数会被完整保留在浏览器历史记录里,而POST中的参数不会被保留。
  • GET请求在URL中传送的参数是有长度限制。(大多数)浏览器通常都会限制url长度在2K个字节,而(大多数)服务器最多处理64K大小的url。
  • GET产生一个TCP数据包;POST产生两个TCP数据包。对于GET方式的请求,浏览器会把http header和data一并发送出去,服务器响应200(返回数据);而对于POST,浏览器先发送header,服务器响应100(指示信息—表示请求已接收,继续处理)continue,浏览器再发送data,服务器响应200 ok(返回数据)。

07 / HTTP状态码

所有HTTP响应的第一行都是状态行,依次是当前HTTP版本号,3位数字组成的状态代码,以及描述状态的短语,彼此由空格分隔。

状态代码的第一个数字代表当前响应的类型:

  • 1xx消息——请求已被服务器接收,继续处理
  • 2xx成功——请求已成功被服务器接收、理解、并接受
  • 3xx重定向——需要后续操作才能完成这一请求
  • 4xx请求错误——请求含有词法错误或者无法被执行
  • 5xx服务器错误——服务器在处理某个正确请求时发生错误

虽然 RFC 2616 中已经推荐了描述状态的短语,例如”200 OK”,”404 Not Found”,但是WEB开发者仍然能够自行决定采用何种短语,用以显示本地化的状态描述或者自定义信息。

image-20230221105140546

更多状态码:https://baike.baidu.com/item/HTTP%E7%8A%B6%E6%80%81%E7%A0%81/5053660?fr=aladdin

5.5 服务器编程基本框架

虽然服务器程序种类繁多,但其基本框架都一样,不同之处在于逻辑处理。

image-20230221105308819

模块 功能
I/O 处理单元 处理客户连接,读写网络数据
逻辑单元 业务进程或线程(一般一个线程去处理一个客户的请求)
网络存储单元 数据库、文件或缓存
请求队列 各单元之间的通信方式

I/O 处理单元是服务器管理客户连接的模块。它通常要完成以下工作:等待并接受新的客户连接,接收客户数据,将服务器响应数据返回给客户端。但是数据的收发不一定在 I/O 处理单元中执行,也可能在逻辑单元中执行,具体在何处执行取决于事件处理模式。

一个逻辑单元通常是一个进程或线程。它分析并处理客户数据,然后将结果传递给 I/O 处理单元或者直接发送给客户端(具体使用哪种方式取决于事件处理模式)。服务器通常拥有多个逻辑单元,以实现对多个客户任务的并发处理。

网络存储单元可以是数据库、缓存和文件,但不是必须的

请求队列是各单元之间的通信方式的抽象。I/O 处理单元接收到客户请求时,需要以某种方式通知一个逻辑单元来处理该请求。同样,多个逻辑单元同时访问一个存储单元时,也需要采用某种机制来协调处理竞态条件。请求队列通常被实现为池的一部分

5.6 两种高效的事件处理模式

服务器程序通常需要处理三类事件:I/O 事件、信号及定时事件。有两种高效的事件处理模式:Reactor和 Proactor,同步 I/O 模型通常用于实现 Reactor 模式异步 I/O 模型通常用于实现 Proactor 模式

01 / 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 上写入服务器处理客户请求的结果。

Reactor 源码解读:https://blog.csdn.net/Ge_yangwen/article/details/128631905

Reactor 模式的工作流程:

image-20230222100236939

02 / Proactor模式

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。

Proactor 模式的工作流程:

image-20230222100634129

03 / 模拟 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 上写入服务器处理客户请求的结果。

同步 I/O 模拟 Proactor 模式的工作流程:

image-20230222101352162

04 / 并发编程模式

并发编程方法的实现有多线程和多进程两种,但这里涉及的并发模式指I/O处理单元与逻辑单元的协同完成任务的方法。

  • 半同步/半异步模式
  • 领导者/追随者模式

05 / 半同步/半反应堆

半同步/半反应堆并发模式是半同步/半异步的变体,将半异步具体化为某种事件处理模式.

并发模式中的同步和异步

  • 同步指的是程序完全按照代码序列的顺序执行
  • 异步指的是程序的执行需要由系统事件驱动

半同步/半异步模式工作流程

  • 同步线程用于处理客户逻辑
  • 异步线程用于处理I/O事件
  • 异步线程监听到客户请求后,就将其封装成请求对象并插入请求队列中
  • 请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象

半同步/半反应堆工作流程(以Proactor模式为例)

  • 主线程充当异步线程,负责监听所有socket上的事件
  • 若有新请求到来,主线程接收之以得到新的连接socket,然后往epoll内核事件表中注册该socket上的读写事件
  • 如果连接socket上有读写事件发生,主线程从socket上接收数据,并将数据封装成请求对象插入到请求队列中
  • 所有工作线程睡眠在请求队列上,当有任务到来时,通过竞争(如互斥锁)获得任务的接管权

5.7 线程池

线程池是由服务器预先创建的一组子线程,线程池中的线程数量应该和 CPU 数量差不多(我们可以动态的去控制线程池的数量,感觉可以参考vector的扩容机制来)。线程池中的所有子线程都运行着相同的代码。当有新的任务到来时,主线程将通过某种方式选择线程池中的某一个子线程来为之服务。相比与动态的创建子线程,选择一个已经存在的子线程的代价显然要小得多。至于主线程选择哪个子线程来为新任务服务,则有多种方式:

  • 主线程使用某种算法来主动选择子线程。最简单、最常用的算法是随机算法和 Round Robin(轮流选取)算法,但更优秀、更智能的算法将使任务在各个工作线程中更均匀地分配,从而减轻服务器的整体压力。
  • 主线程和所有子线程通过一个共享的工作队列来同步,子线程都睡眠在该工作队列上。当有新的任务到来时,主线程将任务添加到工作队列中。这将唤醒正在等待任务的子线程,不过只有一个子线程将获得新任务的”接管权“,它可以从工作队列中取出任务并执行之,而其他子线程将继续睡眠在工作队列上

线程池的一般模型为:

image-20230222101539008

线程池中的线程数量是依据什么确定的?

线程池中的线程数量最直接的限制因素是中央处理器(CPU)的处理器(processors/cores)的数量N如果你的CPU是4-cores的,对于CPU密集型的任务(如视频剪辑等消耗CPU计算资源的任务)来说,那线程池中的线程数量最好也设置为4(或者+1防止其他因素造成的线程阻塞)对于IO密集型的任务,一般要多于CPU的核数,因为线程间竞争的不是CPU的计算资源而是IO,IO的处理一般较慢,多于cores数的线程将为CPU争取更多的任务,不至在线程处理IO的过程造成CPU空闲导致资源浪费。公式:最佳线程数 = CPU当前可使用的Cores数 * 当前CPU的利用率 * (1 + CPU等待时间 / CPU处理时间)

  • 空间换时间,浪费服务器的硬件资源,换取运行效率。
  • 池是一组资源的集合,这组资源在服务器启动之初就被完全创建好并初始化,这称为静态资源
  • 当服务器进入正式运行阶段,开始处理客户请求的时候,如果它需要相关的资源,可以直接从池中获取,无需动态分配
  • 当服务器处理完一个客户连接后,可以把相关的资源放回池中,无需执行系统调用释放资源

5.8 有限状态机

逻辑单元内部的一种高效编程方法:有限状态机(finite state machine)。
有的应用层协议头部包含数据包类型字段,每种类型可以映射为逻辑单元的一种执行状态,服务器可以根据它来编写相应的处理逻辑。如下是一种状态独立的有限状态机:

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;
        }
    }
}

该状态机包含三种状态:type_A、type_B 和 type_C,其中 type_A 是状态机的开始状态type_C 是状态机的结束状态。状态机的当前状态记录在 cur_State 变量中。在一趟循环过程中,状态机先通过getNewPackage 方法获得一个新的数据包,然后根据 cur_State 变量的值判断如何处理该数据包。数据包处理完之后,状态机通过给 cur_State 变量传递目标状态值来实现状态转移。那么当状态机进入下一趟循环时,它将执行新的状态对应的逻辑。

5.9 EPOLLONESHOT事件

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

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

5.10 服务器压力测试

Webbench 是 Linux 上一款知名的、优秀的 web 性能压力测试工具。它是由Lionbridge公司开发。

  • 测试处在相同硬件上,不同服务的性能以及不同硬件上同一个服务的运行状况。
  • 展示服务器的两项内容:每秒钟响应请求数和每秒钟传输数据量。

基本原理:Webbench 首先 fork 出多个子进程,每个子进程都循环做 web 访问测试。子进程把访问的结果通过pipe 告诉父进程,父进程做最终的统计结果。

测试示例

webbench -c 1000 -t 30 http://192.168.110.129:10000/index.html
    参数:
        -c 表示客户端数
        -t 表示时间

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