admin管理员组文章数量:1598882
文章目录
- 前言
- 一、linux 系统编程
- 1.linux 环境的搭建
- 可以在Linux环境下开发 C、C++程序
- 2.GCC
- GCC编译工具
- gcc命令(参考gcc工作流程和常用参数)
- 3.库
- 静态库与动态库
- 静态库的制作和使用
- 动态库的制作和使用
- 4.Makefile
- Makefile 文件
- makefile规则
- make命令
- 5.GDB
- GDB 调试工具
- GDB 命令
- gdb 多进程调试
- 6.文件IO
- 程序和进程
- IO函数
- 二、Linux 多进程开发
- 1. 程序和进程
- 程序
- 进程
- 并行和并发
- 进程的状态
- 进程指令
- 创建进程
- 进程退出(解决孤儿进程)
- 孤儿进程
- 进程回收(解决僵尸进程)
- 2.多进程通信
- 匿名管道 pipe
- 有名管道 FIFO
- 内存映射
- 信号
- 共享内存
- 守护进程
- 三.Linux多线程开发
- 1.线程知识
- 简介
- 进程线程区别
- 线程之间共享和非共享资源
- 2.线程创建
- 线程的创建、终止、连接、分离、取消
- 线程属性
- 3.线程同步
- 线程同步
- 互斥锁/互斥量
- 死锁
- 读写锁
- 生产者消费者模型
- 条件变量
- 信号量
- 四、Linux网络编程
- 1.基本知识
- 网卡 与 MAC地址
- IP地址,IP协议
- 端口
- 网络模型
- 协议
- 网络通信过程
- 字节序
- 2.网络编程相关函数
- socket 地址
- IP地址转换
- 套接字函数
- 3. TCP与UDP
- TCP UDP 比较
- 4. TCP通信
- TCP通信流程
- TCP三次握手
- TCP滑动窗口
- TCP四次挥手
- TCP状态转换
- 并发服务器的实现
- 5. 端口复用
- 6. io多路复用(io多路转接)
- 几种常见的 io模型
- select
- poll
- epoll
- epoll 两种工作模式
- 7. UDP通信
- udp的接受与写入函数
- udp的客户端与服务器
- 8. 广播与组播(多播)
- 广播
- 组播(多播)
- 9. 本地套接字
- 大体内容更新完成,不定时补充
前言
本文主要参照《TCPIP网络编程》尹圣雨、《Linux高性能服务器编程》游双、《Linux 高并发服务器开发》nowcoder等资料。将随着技术精进而不断补充。
目前正在持续更新
一、linux 系统编程
1.linux 环境的搭建
可以在Linux环境下开发 C、C++程序
俯瞰整个流程:
配置一个 Linux 下 Ubuntu 系统:
下载一个虚拟机软件 ,B站有教程。下载 Ubuntu 镜像文件。在虚拟机上创建虚拟机,安装 Ubuntu 系统。
在 Windows 下通过 xshell 和 xftp 或者 VS code 远程连接控制 Ubuntu 来实现 webserver。
我们实际操作的环境并非 Linux环境,而是在 Windows下远程连接到 Linux 来操作的。
下载 xshell, xftp,一个是命令控制终端,一个是文件传输工具。
都需要连接后才可以远程控制传输。连接需要 Ubuntu 系统的用户名+密码即可。
文件传输 可以通过 ftp,也可以通过 VMware tools 实现的直接文件拖拽的方式。
vscode 可以作为 Windows 环境下的编辑工具,来实现远程代码的编辑来代替 Linux 下的 vim 编辑。
2.GCC
GCC编译工具
对源程序进行编译生成可执行文件。
在 Linux Ubuntu系统下安装GCC:
sudo apt install gcc g++
安装命令。apt,Ubuntu下的包管理器。(版本>4.8.5的支持 C++11特性)
gcc/g++ -v/-version
查看gcc版本
gcc 和g++的区别:
对于 .c 文件,用 gcc 命令就认为 .c 文件是C程序;用g++命令就认为 .cpp是C++程序。
而 .cpp 文件,两者都会认为其是一个 c++程序。编译可以用 gcc/g++, 链接可以用个 g++/gcc -lstdc++。因为对于C++程序而言,gcc 命令不能完成和C++程序使用的库链接,所以链接那步通常使用 g++。
预处理
头文件的展开,注释的删除,宏的替换等
链接
静态库/动态库的区别 体现在 制作过程不同,使用方法不同(也就是说链接阶段对两个库的处理方式不同)。
静态库 是在链接阶段直接将静态库文件和源程序打包成一个整体,在程序运行时可以直接运行,因为程序中用到的库文件相关内容已经拷贝到程序中了,就可以直接加载运行了。
而 动态库 在链接时并不会把库中的代码打包到可执行文件中,只是写一些信息链接过去(比如动态库的名称等),当程序运行过程中有调用到动态库中的 api 时,再根据配置好的库的位置信息找到库文件,动态加载器将其加载到内存中供程序使用。
所以说动态库多了环境配置的步骤,要将库的绝对位置配置到环境中,让动态加载器每次在需要的时候可以成功定位到该库,从而可以正常加载到内存中
gcc命令(参考gcc工作流程和常用参数)
gcc -E test.c -o app
生成只经过预处理的 app文件
gcc -S test.c -o app
生成经过编译后生成的汇编文件
gcc -c test.c -o app
生成经过汇编的目标文件,即二进制文件
gcc test.c -o app
test.c:编译的文件; -o: 指定要生成的文件的名字;app:生成的目标文件名。
对于生成的目标文件(可执行程序,Linux下都是绿色的)可以直接 ./app 打开。
gcc main.c -o app -I 库文件所在目录
main.c 在编译链接时,可以成功找到包含的不在同一级目录下的头文件
gcc main.c -o app -I 源文件包含的头文件所在目录 -L 头文件中声明的和其他库文件所在的位置 -l 调用库名
main.c 在编译链接时,可以成功找到包含的不在同一级目录下的头文件
3.库
静态库与动态库
库:可以理解为一个代码仓库,提供给使用者直接拿来用的。内部存储的是二进制代码,并且不能单独使用,在链接阶段和使用了库文件的目标文件链接用的。
优缺点:
静态库
加载速度快;程序移植方便。因为静态库已经被打包到应用程序中了。
也正因为如此,所以如果有几个程序用到同一个静态库,则在运行时会占用多份的内存空间,消耗系统资源。且每次静态库有改动,就需要重新编译一下程序,进行新的链接,所以程序的更新部署发布麻烦。
动态库
可以实现进程间的资源共享,即当一个程序用到的了一个动态库,此动态库已经被加载到内存了,那么当另一个程序也调用到该动态库时,就不需要在内存中重新加载,而是直接用内存的资源就可以了。
更新/部署/发布简单,因为程序中包含的并不是整个库文件,只是库文件的名字等简单信息,更新库后下次调用只要库的绝对路径不变就不会有影响。
并且是实时内存加载,节省内存空间。
但加载速度慢点,且发布时需要提供依赖的动态库,因为链接动态库的程序中并没有整个库文件数据,而是需要动态的加载。
链接阶段
静态库
在链接阶段被用到的代码会被复制到 源程序生成的目标代码中 打包成一个整体,生成可执行文件。那么当可执行程序运行时,静态库的代码也会随之加载到内存中去,直接调用即可。
动态库
而动态库在链接阶段不会被复制到目标文件中,而是程序在运行时由系统动态的加载到内存中供程序调用。也就是说即使在运行过程中,也不是将动态库中被用到的代码实时复制到程序中,而是动态加载到内存中供程序调用。
动态库是在程序运行后,当程序调用到库文件中的 api 时再实时动态的将库加载到内存中,通过 ldd (list dynamic dependencies) 命令可以检查动态库依赖关系。这个过程时通过动态加载器来完成的,通过库的绝对路径找到库文件后将其载入内存。对于linux 下的的可执行程序(elf格式),加载器时 ld-linux.so,它先后搜索 elf 文件的 DT_RPATH段(改变不了,所以我们的动态库的地址不能加在这里),环境变量 LD_LIBRARY_PATH(终端临时环境变量配置), /etc/ld.so.cache 文件列表, /lib/或 /usr/lib/ 目录 来找到库文件后将其载入到内存。
库的好处
代码保密。即使被反编译,还原度也很低。
方便部署和分发。如果想给另一个人发送好多个 源文件,可以将其源文件生成的目标文件统一打包,制作成一个 库,方便分发。分发的时候不仅要把库给别人,还要把库所依赖的头文件一并分发。
静态库的制作和使用
- 命名规则
Linux:libxxx.a | Windows:libxxx.lib
lib:前缀(固定的)
xxx:库的名字,自己定
.a/.lib:后缀(不同的平台不同,是固定的)
-
制作
gcc 编译获得 .o 的二进制目标文件
gcc -c xxx.c xxx.c xxx.c
对若干源文件进行编译,得到 .o 二进制文件
将 .o 文件打包,使用的是 Linux 中的 ar (archive) 工具
ar rcs libxxx.a xxx.o xxx.o
将 libxxx.a 后边跟着的 .o 文件(打包备份 建立索引)创建成库文件
r - 将准备打包的目标文件插入到备存文件中
c - 建立备存文件,也就是我们最后生成的库文件中的内容,可以这么理解
s - 建立备存文件的索引 -
使用
要同时将 头文件和库文件 一块发给别人使用,头文件中是声明,库文件中是定义。
gcc xxx.c -o exe_name -I include_头文件的位置 -L 用到的函数对应库的位置 -l 具体库的名字(libxxx.a 中的 xxx)
即可编译成功,生成一个可执行文件exe_name
该程序在链接阶段就用到了静态库的链接,是在程序的链接阶段就将静态库给复制到源程序生成的目标文件中了。
动态库的制作和使用
- 命名规则
linux: libxxx.so | windows: libxxx.dll
lib:固定前缀
xxx: 库的名字,自己起
.so/.dll,固定后缀,.so是Linux下的动态库后缀,.dll是Windows下的动态库后缀。
-
制作
gcc 编译得到 .o 文件,并且是得到 和位置无关的代码
gcc -c -fpic xxx.c xxx.c xxx.c
gcc 得到动态库
gcc -shared xxx.o xxx.o -o libxxx.so
-
使用
使用之前需要将动态库的绝对路径进行环境配置,使得动态加载器可以定位加载到该动态库。
可以用命令ldd exename
来查看可执行程序的动态库依赖关系。
-
环境变量配置:
- 配置环境:
LD_LIBRARY_PATH
- 临时变量配置:
配置在当前终端,重启终端后环境变量失效。
直接在当前终端export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:要添加的环境变量,即动态库的绝对路径
- 永久变量配置:
- 用户级别:
配置位置:cd 到 根目录home > .bashrc 下进行编辑~/.bashrc
vim .bashrc
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:要添加的环境变量,即动态库的绝对路径
. ./.bashrc
配置保存后,通过该命令使其生效。.等同于source。source ./.bashrc
- 系统级别:
任意位置下都可以编辑,需要管理员权限
sudo vim /etc/profile
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:要添加的环境变量,即动态库的绝对路径
source /etc/profile
- 用户级别:
- 临时变量配置:
- 文件:/etc/ld.so.cache配置
/etc/ld.so.cache
该文件是一个二进制文件,不能直接修改。需要间接修改,通过/etc/ld.so.conf
来配置。
sudo vim /etc/ld.so.conf
,进入到文件中,然后将 环境变量值(动态库的绝对路径)添加到里边保存即可。
sudo ldconfig
更新环境配置 - /lib 或者 /usr/lib 文件配置(不推荐)
将我们的动态库添加到这两个文件夹中去。
因为两个文件中本来就有大量的系统文件,以防我们的动态库文件名和系统文件名重复出现问题,不推荐这种方式。
4.Makefile
Makefile 文件
Makefile 是一个文件,其中定义了一系列的规则来指定哪些文件需要先编译,哪些文件需要后编译,哪些文件需要重新编译等。好处是“自动化编译”,而不是对每个源文件都需要 gcc 指令。
make 是一个解释 makefile 文件中指令的命令工具。通过 make 命令,整个工程就可以完全自动编译,提高开发效率。
makefile规则
一个 makefile 文件中可以有一个或者多个规则,其他规则都是为第一条规则服务的。其他规则也只有为第一条规则服务的时候才会被执行。
目标:依赖
命令(一般时 shell 命令)
#目标:最终要生成的文件,比如说可执行文件
#依赖:生成目标所需要的文件或者目标,比如若干源文件和库文件
#命令:通过执行命令 对依赖操作 生成目标
# 1. makefile 工作原理:
# makefile 中可以写多条规则,第一条是必执行的,之后的规则如果和第一条规则相关,是生成第一条规则的依赖的话,也会执行。
# 并且在第二次运行makefile文件时,会通过比较 目标和依赖的时间 来决定是否执行对应命令。如果目标晚于依赖,不执行更新;反之更新。
# 更新时,对于没有依赖的目标,是去当前目录下是否已经存在该目标,存在则不在更新。不存在则继续更新。 对伪规则不适用
# 2. 内容的书写 有何高效写法:
# 在内容的书写上可以通过 定义变量和使用函数来高效的完成第一条规则的书写,使用模式匹配来完成之后的规则书写。
一个 Makefile 文件的实例
# 定义变量+使用函数
src=$(wildcard ./*.c) /* 获取当前目录下 符合后缀是 .c 的文件列表 */
objs=$(patsubst %.c, %.o, $(src)) /* 将src 中的单词和 %.c 进行模式匹配,匹配成功的就替换成 %.o */
target=app
$(target):$(objs)
$(CC) $(objs) -o $(target)
# 第一条规则中的 objs 都不存在,则会继续往下执行,下边的规则用来通过某个依赖生成需要的目标
%.o:%.c # 模式匹配 目标和依赖,下边指令 将对应的依赖编译成目标文件
$(CC) -c $< -o $@
.PHONY:clean
clean:
rm ./*.o -f
make命令
make 解释当前目录下的 Makefile 文件中的规则命令
make 指定目标 只执行指定目标所在规则的指令
5.GDB
GDB 调试工具
和 GCC 编译工具 组成了一套完整的开发环境。
准备工作,编译时加 调试参数 得到可调试的程序
可选:通过 gcc常用参数 关掉编译器的优化选项 -O0’
必选:打开调试选项 -g ; 在不影响程序行为的情况下打开所有 warning -Wall
例子: gcc program.c -o program -g -Wall
-g 选项的作用并不是把整个源代码给嵌入到可执行文件中,而是将源代码位置信息加入到可执行文件中,使得在调试时 gdb 能够找到源文件,通过发现bug 的机器指令的位置对应到源程序相应的位置。
GDB 命令
gdb 多进程调试
因为使用 gdb 进行调试的时候,gdb 默认只能跟踪一个进程。所以对于多进程调试,需要用过一些指令来设置跟踪的是哪个进程。
又因为任何一个进程(除init进程)都是由另一个进程创建出来的,比如说 A 进程创建了 B进程,则 A进程称为父进程,B称为子进程。对于这种调用 fork 函数而出现的父子进程 在调试的时候 gdb 默认跟踪父进程;并且子进程自动脱离父进程,父进程的调试和子进程无关,子进程如果是就绪状态的话,会在到了自己的时间片时就去执行。
我们可以通过指令来设置 gdb 调试跟踪的进程以及 fork创建的函数是否脱离父进程:
show follow-fork-mode
查看gdb跟踪的是父进程还是子进程
set follow-fork-mode parent|child
设置 gdb 调试跟踪的进程
show detach-on-fork
查看 调试模式,默认为 on, 表示调试当前进程时,其他进程继续运行
set detach-on-fork on|off
可以设置为 off,表示调试当前进程时,其他进程被 gdb 挂起
info inferiors
查看调试的进程有哪些
inferior id
切换当前调试的进程
detach inferiors id
使 进程id 脱离gdb调试
6.文件IO
Linux 中有 7 中文件类型,FIFO管道类型算一种。
每种类型都可以跟普通文件一样 open(), read(), write(), close()
程序和进程
程序的源程序以及可执行文件,都是文件,是存储在磁盘上的,占用磁盘空间,不占用内存空间。
那当可执行程序想要运行的话,系统就会为其创建一个进程,为该程序分配一些资源,占用内存空间。
一个程序启动以后,会有一个虚拟地址空间(内存),虚拟地址空间会通过 cpu 中的 内存管理单元(MMU)将数据映射到物理内存中。
IO函数
linux系统 IO函数
◼ 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);
//移动文件指针位置,根据第二个和第三个参数将指针移动到文件头,获取当前指针位置,移动到文件尾,扩展文件大小等。
//fd:文件描述符,通过open得到的,通过这个fd操作某个文件
//offset:偏移量
//whence: SEEK_SET 设置文件指针的偏移量
// SEEK_CUR 设置偏移量:当前位置 + 第二个参数offset的值
// SEEK_END 设置偏移量:文件大小 + 第二个参数offset的值
//返回值:返回文件指针的位置
◼ int stat(const char *pathname, struct stat *statbuf);
◼ int lstat(const char *pathname, struct stat *statbuf);
//stat用来获取普通的文件信息,lstat用来获取软链接文件的信息,像ll 打印出来的那些信息,可以通过 stat,lstat函数获取到。得到的文件信息 保存在 statbuf 指向的 buf中,可以通过指针 statbuf 得到。
◼ perro("aaa");
lseek作用
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)
注意:需要写一次数据write(fd, " ", 1);
文件属性操作函数
◼ int access(const char *pathname, int mode);
// 判断文件是否存在以及文件的权限。mode:F_OK,R_OK,W_OK,X_OK
◼ int chmod(const char *filename, int mode);
// 修改文件权限
◼ int chown(consdpt char *path, uid_t owner, gid_t group);
// 修改文件的所有者,所在组.
// 查看用户的id,组别id:`vim /etc/passwd` `vim /etc/group`
◼ int truncate(const char *path, off_t length);
// 缩减/扩展文件大小到指定的大小
◼ int dup(int oldfd);
// 复制文件描述符。复制得到的文件描述符返回值和 oldfd值不同,却指向同一个文件。 找一个空闲的最小的描述符
◼ int dup2(int oldfd, int newfd);
// 重定向文件描述符。将 newfd 指向的文件 close(如果有的话)
// 并且重定向指向 oldfd 指向的文件。同样是 oldfd,newfd 维护同一个文件。
//例如,oldfd 指向 a.txt, newfd 指向 b.txt
// 调用函数成功后:newfd 和 b.txt 做close, newfd 指向了 a.txt
// oldfd 必须是一个有效的文件描述符
// oldfd和newfd值相同,相当于什么都没有做
// 该返回值和 newfd 相同。
◼ int fcntl(int fd, int cmd, ...);
// 根据 cmd 的不同,可以做好多事。可以通过手册看 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 设置成非阻塞
目录操作、遍历函数
◼ int mkdir(const char *pathname, mode_t mode);
// 创建一个目录,并且指定权限
◼ int rmdir(const char *pathname);
// 移除空目录
◼ int rename(const char *oldpath, const char *newpath);
◼ char *getcwd(char *buf, size_t size);
// 获取当前工作目录。buf 是存储路径,指向一个数组 size为大小,
// 返回的指向的一块内存,这个数据就是第一个参数
◼ int chdir(const char *path);
// 修改进程的工作目录。
// 参数是需要修改的工作目录;返回值是一个指针,指向一块内存,也就是第一个参数
// 目录遍历操作组合:打开目录>读取目录>关闭目录
◼ DIR *opendir(const char *name);
// 打开一个目录,针对该目录中的文件会返回一个 目录流,流中是当前目录下的各个文件
◼ struct dirent *readdir(DIR *dirp);
// 将目录流对象作为参数进行 read,会遍历目录流中的文件,并且遍历每个文件 dirp是opendir返回的结果
// 有一个结构体返回值,结构体中含有该文件的一些信息,如文件名、文件类型等。
◼ int closedir(DIR *dirp);
二、Linux 多进程开发
从进程入手,了解什么是进程,进程的状态、状态间的转移,进程的创建、退出和回收,因为进程创建而出现的孤儿、僵尸进程怎么处理(通过进程回收 wait/waitpid 来解决等。)
然后是多进程,多进程之间是如何通讯的。针对同一台主机上的进程和不同主机上的进程有不同的通信方式:不同主机的通信是网络编程涉及到的东西(socket通信);同一主机的通信方式有很多种:管道通信(匿名、有名)、信号、信号量、共享内存、内存映射、消息队列。
1. 程序和进程
程序
程序是包含一系列信息的文件,存储在磁盘中,不占用内存和CPU资源;它描述如何在程序运行时创建一个进程。(程序中的文件包括:二进制格式标识文件的元信息,机器语言指令,程序入口地址,数据,共享库和动态链接信息等。)
进程
是正在运行的程序的实例,会占用内存空间和CPU资源等各项系统资源来执行程序。每个进程对应一个虚拟地址空间,进程是一个抽象出来的实体,可以理解为由用户内存区和内核区组成,用户区包含程序代码和变量常量等数据信息,内核区则用于维护进程状态信息(如进程标识号、当前工作目录、打开的文件描述符、进程资源使用及限制等)。
进程的虚拟地址空间如下:
时间片
是操作系统为正在运行的进程分配的 CPU执行时间,因为一个 CPU 在同一时刻只能处理一个进程,宏观上看我们的程序好像是在同时运行,就是因为 进程其实是在 轮流被CPU处理,cpu 不停的在多个进程之间来回切换。
PCB 进程控制块
为了管理进程,内核为每个进程分配一个 pcb 来维护进程相关信息。
pcb 是一个 task_struct 结构体(在Linux中),常用成员有:
- 进程id:每个进程有唯一的 id
- 进程状态:就绪、运行、挂起、停止等
- 进程切换时需要保存和恢复的信息
- 描述虚拟地址空间的信息
- 描述控制终端的信息:每个进程运行会绑定好自己的终端
- 当前工作目录
- umask 掩码
- 文件描述符表:包含指向 FILE 结构体的指针
- 和信号相关的信息
- 用户 id,组 id
- 会话和 进程组
- 进程可以拥有的资源上限: ulimit -a显示当前进程的资源上限
并行和并发
并行(parallel)
指在同一时刻,有多条指令在多个处理器上同时执行
并发(concurrency)
指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使
得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干
段,使多个进程快速交替的执行
并发是两个队列交替使用一台咖啡机。并行是两个队列同时使用两台咖啡机。
进程的状态
可以反映进程执行过程的变化。
这种状态随着进程的执行和外界条件的变化为转换,比如说运行态的进程在时间片用完后进入到就绪态或者终止态,阻塞态的进程在其他进程所占用资源释放后 具备了除CPU以外的所有必须资源 就会进入到就绪队列等待运行。
三态模型
- 运行态:进程占有处理器正在运行
- 就绪态:进程具备了除 CPU 所有以外的所有必要资源, 只要在获得处理器,就可以立即执行的状态。
而在一个系统中处于就绪态的进程可能有多个,通常将他们排成一个对列,成为就绪队列 - 阻塞态:指进程不具备除CPU以外的运行条件,正在等待某个事件的完成。有 等待态(wait) 和 睡眠态(sleep),等待态等待如 io请求。
五态模式
- 新建态:进程刚被创建时的状态,尚未进入就绪队列
- 终止态:进程正常执行完毕到达结束点、或出现错误而异常终止、或被操作系统及有终止权的进程所终止时所处的状态。
进入终止态的进程不在执行,但依然保留在操作系统中等待善后,一旦其他进程完成了对终止态进程的内核信息的释放之后,该进程被彻底删除。
进程指令
查看进程
ps aux / ajx
a:显示终端上的所有进程,包括其他用户的进程
u:显示进程的详细信息
x:显示没有控制终端的进程
j:列出与作业控制相关的信息
STAT参数意义:
D 不可中断 Uninterruptible(usually IO)
R 正在运行,或在队列中的进程
S 处于休眠状态
T 停止或被追踪
Z 僵尸进程
W 进入内存交换(从内核2.6开始无效)
X 死掉的进程
< 高优先级
N 低优先级
s 包含子进程
+ 位于前台的进程组
实时显示进程动态
可以在使用top
命令时加上 -d 来指定显示信息更新的时间间隔,在 top 命令执行后,可以按以下按键对显示的结果进行排序:
- M 根据内存使用量排序
- P 根据 CPU 占有率排序
- T 根据进程运行时间长短排序
- U 根据用户名来筛选进程
- K 输入指定的 PID 杀死进程
杀死进程
kill名并不是去杀死一个进程,而是给进程发送某个信号
kill [-signal] pid
kill –l 列出所有信号
kill –SIGKILL 进程ID
kill -9 进程ID
killall name 根据进程名杀死进程
进程号
每个进程都由唯一的进程号来标识,其类型为 pid_t(整型),进程号的范围:0~32767。进程号总是唯一的,但可以重用。当一个进程终止后,其进程号就可以再次使用。
任何进程(除 init 进程)都是由另一个进程创建,该进程称为被创建进程的父进程,对应的进程号称为父进程号(PPID)。
进程组是一个或多个进程的集合。他们之间相互关联,进程组可以接收同一终端的各种信号,关联的进程有一个进程组号(PGID)。默认情况下,当前的进程号会当做当前的进程组号。
pid_t getpid(void);
pid_t getppid(void);
pid_t getpgid(pid_t pid);
创建进程
我们在 Linux 系统编程、多进程开发的过程中其实是在不断的熟悉使用 linux programmer’s manual 中的函数的过程。
可以通过 man 2/3
函数名 来查看函数的手册。
创建进程用到的函数 - fork - create a child process man 2 fork
fork() 时两个内存空间的内容时一样的,但是之后对数据的操作相互独立,互不干扰。
fork() 函数
实现新进程的创建用调用的函数为 fork()
函数。
pid_t fork(void);
函数的作用:用于创建子进程。
返回值:
fork()的返回值会返回两次。一次是在父进程中,一次是在子进程中。
在父进程中返回创建的子进程的ID,
在子进程中返回0
如何区分父进程和子进程:通过fork的返回值。
在父进程中返回-1,表示创建子进程失败,并且设置errno
父子进程之间的关系:
区别:
1.fork()函数的返回值不同
父进程中: >0 返回的子进程的ID
子进程中: =0
2.pcb中的一些数据
当前的进程的id pid
当前的进程的父进程的id ppid
信号集
共同点:
某些状态下:子进程刚被创建出来,还没有执行任何的写数据的操作
- 用户区的数据
- 文件描述符表
父子进程对变量是不是共享的?
- 刚开始的时候,是一样的,共享的。如果修改了数据,不共享了。
- 读时共享(子进程被创建,两个进程没有做任何的写的操作),写时拷贝。
Linux 的 fork() 使用是通过写时拷贝 (copy- on-write) 实现。写时拷贝是一种可以推迟甚至避免拷贝数据的技术。内核此时并不复制整个进程的地址空间,而是让父子进程共享同一个地址空间。只用在需要写入的时候才会复制地址空间,从而使各个进行拥有各自的地址空间。也就是说,资源的复制是在需要写入的时候才会进行,在此之前,只有以只读方式共享。
注意:fork之后父子进程共享文件,fork产生的子进程与父进程相同的文件文件描述符指向相同的文件表,引用计数增加,共享文件偏移指针。
exec 函数族
exec 函数族的作用和 fork() 函数不一样,fork 会创建一个子进程,也就是说当前进程 调用fork 后,会创建处一个新进程;
而一个进程如果执行过程中调用了 exec函数族的话,并不会新创建一个进程,而是由 exec函数族指定的程序来 取代当前程序继续执行,前后是执行的是两个不同的程序内容,但是用一个进程号。也就是说内核区不变,而用户区会做变化,由新的用户区取代原来的用户区继续执行。
(execlp("ps", "ps", "aux", null)
则 ps aux 的进程取代该进程继续执行
常用的 exec函数族
int execl(const char *path, const char *arg, ...);
- 参数:
- path:需要指定的执行的文件的路径或者名称
a.out /home/nowcoder/a.out 推荐使用绝对路径
./a.out hello world
- arg:是执行可执行文件所需要的参数列表
第一个参数一般没有什么作用,为了方便,一般写的是执行的程序的名称
从第二个参数开始往后,就是程序执行所需要的的参数列表。
参数最后需要以NULL结束(哨兵)
- 返回值:
只有当调用失败,才会有返回值,返回-1,并且设置errno
如果调用成功,没有返回值。
int execlp(const char *file, const char *arg, ... );
- 会到环境变量中查找指定的可执行文件,如果找到了就执行,找不到就执行不成功。
- 参数:
- file:需要执行的可执行文件的文件名
a.out
ps
- arg同上
- 返回值同上
int execv(const char *path, char *const argv[]);
argv是需要的参数的一个字符串数组
char * argv[] = {"ps", "aux", NULL};
execv("/bin/ps", argv);
进程退出(解决孤儿进程)
通过调用 函数来退出当前进程。
Linux中的 _exit(int status)
该函数会立即终止调用该函数的进程;
并且会将该进程打开的文件描述符关闭;
C标准库中的 exit()
该函数会导致正常的进程终止,并且会调用 _exit 返回给父进程一个退出状态。无返回值。
缓冲区中的流数据会被刷新并关闭;临时创建的文件会被移除。
在每个进程退出的时候,内核会释放掉该进程的所有资源,包括打开的文件、占用的内存空间等。
但是还有一些内核区信息 进程控制块pcb的信息(包括进程号、退出状态等)仍然没有被释放,需要由其父进程进行回收释放。
孤儿进程
父进程运行结束,但子进程还在运行(未运行结束),这样的子进程就称为孤儿进程(Orphan Process)。
每当出现一个孤儿进程的时候,内核就把孤儿进程的父进程设置为 init ,而 init 进程会循环地 wait() 它的已经退出的子进程。这样,当一个孤儿进程凄凉地结束了其生命周期的时候,init 进程就会处理它的一切善后工作。因此孤儿进程并不会有什么危害。
进程回收(解决僵尸进程)
进程终止时,父进程尚未回收,子进程残留资源(PCB)存放于内核中,变成僵尸(Zombie)进程。
僵尸进程不能被 kill -9 杀死,这样就会导致一个问题,如果父进程不调用 wait() 或 waitpid() 的话,那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵尸进程,将因为没有可用的进程号而导致系统不能产生新的进程,此即为僵尸进程的危害,应当避免
子进程退出后需要父进程来回收资源。可以通过 wait() waitpid() 以及 发送信号给父进程,父进程进行信号捕捉来 回收子进程资源。
进程退出时会返回给父进程一个进程退出状态,父进程可以通过得到的子进程退出状态进行进程回收。
父进程可以通过调用 wait / waitpid 得到进程的退出状态同时彻底清除该进程。对于父进程循环执行,而此时子进程已经执行完毕,退出进程但还没有被完全回收 的僵尸进程,通过调用 wait 函数可以接收到子进程的退出状态,从而对进程的剩余资源做回收操作。
一次 wait / waitpid 调用只能清理一个子进程,清理多个子进程需要循环。
wait 函数
调用wait函数的进程会被挂起(阻塞),直到它的一个子进程退出或者收到一个不能被忽略的信号时才被唤醒(相当于继续往下执行)
如果没有子进程了,函数立刻返回,返回-1;如果子进程都已经结束了,也会立即返回,返回-1。
pid_t wait(int *wstatus);
功能:等待任意一个子进程结束,如果任意一个子进程结束了,次函数会回收子进程的资源。
参数:int *wstatus
进程退出时的状态信息,传入的是一个int类型的地址,传出参数。
返回值:
- 成功:返回被回收的子进程的id
- 失败:-1 (所有的子进程都结束,调用函数失败)
调用wait函数的进程会被挂起(阻塞),直到它的一个子进程退出或者收到一个不能被忽略的信号时才被唤醒(相当于继续往下执行)
如果没有子进程了,函数立刻返回,返回-1;如果子进程都已经结束了,也会立即返回,返回-1.
while(1) {
int st; // 进程退出状态
int ret = wait(&st);
if(ret == -1) {
break;
}
if(WIFEXITED(st)) {
// 是不是正常退出
printf("退出的状态码:%d\n", WEXITSTATUS(st));
}
if(WIFSIGNALED(st)) {
// 是不是异常终止
printf("被哪个信号干掉了:%d\n", WTERMSIG(st));
}
}
waitpid 函数
wait 和 waitpid 函数的功能一样,区别是 wait 的调用是阻塞的,而 waitpid可以设置不阻塞。
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 :错误,或者没有子进程了
while(1) {
int st;
int ret = waitpid(pid, &st, options);
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));
}
}
}
2.多进程通信
进程是一个独立的资源分配单元,不同进程之间的资源是相互独立的,不能在一个进程中直接访问另一个进程的资源。
但是进程不是孤立的,进程之间需要进行信息的交互和状态的传递等,因此需要 进程间通信(IPC - inter process communication)。
进程间通信的目的:
- 数据传输:一个进程需要将其数据发送给另一个进程;
- 资源共享:多个进程之间共享同样的数据。这块会涉及到内核提供的互斥和同步机制。
- 通知事件:一个进程需要向另一个进程发送消息,通知另一个进程发生了某种事件(如子进程终止时需要通知父进程)。
- 进程控制:一个进程希望完全控制另一个进程的执行(如 debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
进程间通信的方式有很多种:
匿名管道 pipe
int pipe(int pipefd[2]);
功能:创建一个匿名管道,用来进程间通信。
参数:int pipefd[2] 这个数组是一个传出参数。
pipefd[0] 对应的是管道的读端
pipefd[1] 对应的是管道的写端
返回值:
成功 0
失败 -1
管道默认是阻塞的:如果管道中没有数据,read阻塞,如果管道满了,write阻塞
注意:匿名管道只能用于具有关系的进程之间的通信(父子进程,兄弟进程)
匿名管道可以理解为 两个进程都可以操作的一个中间文件,它有缓冲区,来存储一个进程写的数据,供另一个进程读取,从而实现两个进程间的通信。
两个进程想要通过 匿名管道 进行通信,就需要先创建一个管道(通过 pipe 函数创建),创建好管道后会有两个管道口生成,pipefd[0] 用来读取管道中的数据,pipefd[1]用来向管道中写数据。这样两个进程就可以通过对管道的读写数据实现进程间的通信了。
pipe
create a pipe
ulimit -a
查看管道的缓冲区大小
fpathconf
函数,可以通过该函数来实现查看管道的缓冲区大小
管道通信存在的问题
使用管道通信时如果在父子进程中 同时有读写数据 的操作,则有可能会出现自己写的数据自己读取的情况。比如说 父进程中同时有往管道中读写数据的实现,刚往管道中写完数据后时间片还没用完,就会继续往下执行,然后就会把自己写进去的数据读取出来。
所以对于管道通信,都只进行单方向的通信,如果父进程时读取管道,则会关闭写操作,以免发生错误。
close pipefd[0]
关闭读端
close pipefd[1]
关闭写端
管道的特点
管道其实是一个在内核内存中维护的缓冲器,这个缓冲器的存储能力是有限的,不同的操作系统大小不一定相同。
管道拥有文件的特质:读操作、写操作,匿名管道没有文件实体,有名管道有文件实体,但不存储数据。可以按照操作文件的方式对管道进行操作。
一个管道是一个字节流,使用管道时不存在消息或者消息边界的概念,从管道读取数据的进程可以读取任意大小的数据块,而不管写入进程写入管道的数据块的大小是多少。
通过管道传递的数据是顺序的,从管道中读取出来的字节的顺序和它们被写入管道的顺序是完全一样的。
在管道中的数据的传递方向是单向的,一端用于写入,一端用于读取,管道是半双工的。
从管道读数据是一次性操作,数据一旦被读走,它就从管道中被抛弃,释放空间以便写更多的数据,在管道中无法使用 lseek() 来随机的访问数据。
匿名管道只能在具有公共祖先的进程(父进程与子进程,或者两个兄弟进程,具有亲缘关系)之间使用
管道的读写特点
使用管道时,需要注意以下几种特殊的情况(假设都是阻塞I/O操作)
-
所有的指向管道写端的文件描述符都关闭了(管道写端引用计数为0),有进程从管道的读端读数据,那么管道中剩余的数据被读取以后,再次read会返回0,就像读到文件末尾一样。
-
如果有指向管道写端的文件描述符没有关闭(管道的写端引用计数大于0),而持有管道写端的进程也没有往管道中写数据,这个时候有进程从管道中读取数据,那么管道中剩余的数据被读取后,再次read会阻塞,直到管道中有数据可以读了才读取数据并返回。
-
如果所有指向管道读端的文件描述符都关闭了(管道的读端引用计数为0),这个时候有进程向管道中写数据,那么该进程会收到一个信号SIGPIPE, 通常会导致进程异常终止。
-
如果有指向管道读端的文件描述符没有关闭(管道的读端引用计数大于0),而持有管道读端的进程也没有从管道中读数据,这时有进程向管道中写数据,那么在管道被写满的时候再次write会阻塞,直到管道中有空位置才能再次写入数据并返回。
总结:
- 读管道:
- 管道中有数据,read返回实际读到的字节数。
- 管道中无数据:
- 写端被全部关闭,read返回0(相当于读到文件的末尾)
- 写端没有完全关闭,read阻塞等待
- 写管道:
- 管道读端全部被关闭,进程异常终止(进程收到SIGPIPE信号)
- 管道读端没有全部关闭:
- 管道已满,write阻塞
- 管道没有满,write将数据写入,并返回实际写入的字节数
匿名管道实现 ps aux | grep root
子进程实现 ps aux, 将左右的进程输出
父进程将子进程中的信息进行处理,最后只输出 root 相关的进程信息
此时就会涉及到父子进程通信的知识,因为需要先子进程实现 ps aux,然后将 得到的数据写到管道中,父进程再读取管道中的数据进而处理并显示。
创建管道 pipe
创建父子进程 fork
用另一个进程取代子进程 execlp 来实现 ps aux 的操作,而 exec族默认输出到终端,我们需要其输出到 管道中,所以需要重定向输出 dup2
有名管道 FIFO
Linux 中有 7 中文件类型,FIFO管道类型算一种。
有名管道有文件实体,但内存并不在磁盘存储,而是存放在内核内存缓冲区中;匿名管道没有文件实体,其实是内核区的一段缓冲区。
就是说我们通过命令行也好,通过函数也好,创建出来的有名管道是有文件实体的,可以 ll
看到,并且向普通文件一样使用,open
write
read
access
先判断管道是否已经存在等。但是存储数据为 0,写读数据都是在内核缓冲区就完成了,不会存储到管道文件中。
而创建出来的匿名管道是没有文件实体的,是通过固定的 pipefd[0], pipefd[1] 来读写数据的,也是在内核缓冲区完成。
一旦使用 mkfifo 创建了一个 FIFO,就可以使用 open 打开它,常见的文件I/O 函数都可用于 fifo。如:
close、read、write、unlink 等。
命令:mkfifo 名字
函数:int mkfifo(const char *pathname, mode_t mode);
- pathname: 管道名称的路径
- mode: 文件的权限 和 open 的 mode 是一样的, 是一个八进制的数
返回值:成功返回0,失败返回-1,并设置错误号
就是说我们通过命令行也好,通过函数也好,创建出来的有名管道是有文件实体的,可以 ll
看到,并且向普通文件一样使用,open
write
read
access
先判断管道是否已经存在等。但是存储数据为 0,写读数据都是在内核缓冲区就完成了,不会存储到管道文件中。
而创建出来的匿名管道是没有文件实体的,是通过固定的 pipefd[0], pipefd[1] 来读写数据的,也是在内核缓冲区完成。
内存映射
通过内存映射可以实现进程间的通信,原理 是多个进程共享同一个映射内存,一个进程对给内存进行修改,其他进程都可以通过该共享内存的地址访问到。(文件映射)
以及文件间的拷贝。原理 是分别将两个文件映射到内存中,然后做内存间的拷贝 memcpy,内存间的拷贝将同步到文件的拷贝。
以及父子进程间的通信(匿名映射),此时不需要文件实体,涉及到文件描述符和文件大小的参数 -1 和 自定义,其他步骤相同。
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 :不同步,内存映射区的数据改变了,对原来的文件不会修改,会重新创建一个新的文件
- MAP_ANONYMOUS 匿名映射:不需要文件实体进程一个内存映射
- 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参数的值一样。
内存映射注意事项
- 如果对mmap的返回值(ptr)做++操作(ptr++), munmap是否能够成功?
可以对ptr进行++操作,但是munmap会错误,需要提前保存++前的内存地址 - 如果open时O_RDONLY, mmap时prot参数指定 PROT_READ | PROT_WRITE 会怎样?
错误, 会返回 MAP_FAILED (即,void* -1)
open() 函数中的权限建议和prot参数的权限保持一致。 - 如果文件偏移量为1000会怎样?
偏移量必须是4k的整数倍, 返回MAP_FAILED (分页大小) - mmap什么情况下会调用失败?
- 第二个参数: length = 0
- 第三个参数: prot权限有问题
- 只指定了写权限
- prot PROT_READ | PROT_WRITE
第五个参数fd 通过open函数时 未指定 O_RDONLY | O_WRONLY
- 可以open的时候O_CREATE一个新文件来创建映射区吗?
- 可以, 但是创建文件大小为0的话,肯定不行
- 可以对新的文件进行扩展
- lseek()
- truncate()
- mmap后关闭文件描述符,对mmap映射有没有影响?
int fd = open(“xxx”)
mmap(, fd, 0);
close(fd);
映射区还存在, 创建映射区的fd被关闭, 没有任何影响 - 对ptr越界操作会怎样?
越界操作,操作的是非法内存,-> 段错误
使用内存映射实现进程间通信:
- 有关系的进程(父子进程)
- 还没有子进程的时候
- 通过唯一的父进程,先创建内存映射区
- 有了内存映射区以后,创建子进程
- 父子进程共享创建的内存映射区
- 没有关系的进程间通信
- 准备一个大小不是0的磁盘文件
- 进程1 通过磁盘文件创建内存映射区
- 得到一个操作这块内存的指针
- 进程2 通过磁盘文件创建内存映射区
- 得到一个操作这块内存的指针
- 使用内存映射区通信
注意:内存映射区通信,是非阻塞。
信号
如 SIGINT, SIGQUIT, SIGALRM, SIGCHLD 信号等、
用于进程间的通信,是事件发生时对进程的通知机制,告知另一个进程发生了某个事件。(软件中断)他是在软件层次上对中断机制的一种模拟,是一种异步通信方式。信号可以导致一个正在运行的进程被另一个正在运行的异步进程中断,转而去处理某一个突发事件。
引发内核为进程产生信号的事件有:
- 前台进程接收到了 ctrl + c
- 硬件发生异常:硬件检测到一个错误条件并通知内核,随即再由内核发送相应信号给相关内核。
- 系统状态发生变化:如 alarm定时器到期 会引发 SIGALRM 信号,进程执行的 CPU时间超限等。
- 运行kill 命令 或调用 kill函数
信号的 5 种默认处理动作
- Term 终止进程
- Ign 当前进程忽略掉这个信号
- Core 终止进程,并生成一个Core文件
- Stop 暂停当前进程
- Cont 继续执行当前被暂停的进程
信号的状态
- 创建
- 未决:信号没有被处理
- 递达:信号被处理了
常用函数
ulimit -a
中的 core file size 的用法:当访问了非法内存等时会报 段错误的提示,并且核心已转储,生成一个 core 文件。前提是 编译xxx.c文件时 加上调试信息,并且ulimit -a
查看core file size 不为0,则会生成一个 core文件。用来调试用。
gcc xxx.c -g -o xxx
gdb xxx
core-file core
可以查看具体的段错误的位置及错误详情。
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);
void abort(void);
- 功能: 发送SIGABRT信号给当前的进程,杀死当前进程 = kill(getpid(), SIGABRT);
定时器
unsigned int alarm(unsigned int seconds);
- 功能:设置定时器(闹钟)。函数调用,开始倒计时,当倒计时为0的时候,
函数会给当前的进程发送一个信号:SIGALARM
- 参数:
seconds: 倒计时的时长,单位:秒。如果参数为0,定时器无效(不进行倒计时,不发信号)。
取消一个定时器,通过alarm(0)。
- 返回值:
- 之前没有定时器,返回0
- 之前有定时器,返回之前的定时器剩余的时间
- SIGALARM :默认终止当前的进程,每一个进程都有且只有唯一的一个定时器。
alarm(10); -> 返回0
过了1秒
alarm(5); -> 返回9
alarm(100) -> 该函数是不阻塞的
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 并设置错误号
信号捕捉
SIGKILL\ SIGSTOP 不能被捕捉,不能被阻塞,不能被忽略。
因为间隔定时器延时时间倒计时为0后,就会给进程发送一个SIGALRM 信号将进程杀死,所以后续的时间间隔定时器作用就相当于没有用。需要信号捕捉来实现时间间隔的操作。
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
- 功能:设置某个信号的捕捉行为
- 参数:
- signum: 要捕捉的信号
- handler: 捕捉到信号要如何处理
- SIG_IGN : 忽略信号
- SIG_DFL : 使用信号默认的行为
- 回调函数 : 这个函数是内核调用,程序员只负责写,捕捉到信号后如何去处理信号。
回调函数:
- 需要程序员实现,提前准备好的,函数的类型根据实际需求,看函数指针的定义
- 不是程序员调用,而是当信号产生,由内核调用
- 函数指针是实现回调的手段,函数实现之后,将函数名放到函数指针的位置就可以了。
- 返回值:
成功,返回上一次注册的信号处理函数的地址。第一次调用返回NULL
失败,返回SIG_ERR,设置错误号
SIGKILL SIGSTOP不能被捕捉,不能被忽略
推荐使用
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);
};
使用方法可参考:
struct sigaction act; //可以memset(&act, '\0', sizeof(act))给它置空
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
act.sa_handler = recyleChild;//函数
// 注册信号捕捉
sigaction(SIGCHLD, &act, NULL);
信号集
信号的集合即为信号集,存储在内核中,有 未决信号集(由内核决定,不能修改) 和 阻塞信号集(用户可修改)。64位信号集
- 用户通过键盘键入 CTRL+C, 产生信号 SIGINT (信号被创建);
- 信号被创建但还没被处理,处于 未决 状态,所有的未决信号存储在内核中的一个集合 未决信号集 中。标志位值为 1,说明信号处于未决状态;0 说明已被处理,为递送态。
- 处于未决信号集中的未决信号在被递送前要和另一个集合(阻塞信号集)进行对位比较,如果阻塞信号集的该信号为 0 非阻塞,未决信号就被处理;如果阻塞信号集中该信号为 1为阻塞态,则该信号就继续等待,直到阻塞解除,该信号被处理。
- 这块涉及到的 set 都是自定义信号集,对自定义信号集进行操作。
sigset_t set; 创建一个信号集,其内数据随机。
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 : 失败
- 对内核的信号集 进程操作,需要通过系统提供的 api 进行操作。
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
- 功能:将自定义信号集中的数据设置到内核中(设置阻塞,解除阻塞,替换)
- 原理是我们先自定义一个阻塞信号集,将其设置为我们想要的信号阻塞状态,然后调用该函数来将其映射到内核中。
- 参数:
- how : 如何对内核阻塞信号集进行处理
SIG_BLOCK: 将用户设置的阻塞信号集添加到内核中,内核中原来的数据不变
假设内核中默认的阻塞信号集是mask, mask | set
SIG_UNBLOCK: 根据用户设置的数据,将用户信号集中的 1 的位置的信号,对内核中的数据进行解除阻塞
mask &= ~set set取反即把想要非阻塞的信号置为0了
SIG_SETMASK:覆盖内核中原来的值
- set :已经初始化好的用户自定义的信号集
- oldset : 保存设置之前的内核中的阻塞信号集的状态,可以是 NULL
- 返回值:
成功:0
失败:-1
设置错误号:EFAULT、EINVAL
int sigpending(sigset_t *set);
- 功能:获取内核中的未决信号集
- 参数:set,传出参数,保存的是内核中的未决信号集中的信息。
- 返回值:
成功:0
失败:-1
阻塞信号集和未决信号集
- 用户通过键盘 Ctrl + C, 产生2号信号SIGINT (信号被创建)
- 信号产生但是没有被处理 (未决)
- 在内核中将所有的没有被处理的信号存储在一个集合中 (未决信号集)
- SIGINT信号状态被存储在第二个标志位上
- 这个标志位的值为0, 说明信号不是未决状态
- 这个标志位的值为1, 说明信号处于未决状态
- 这个未决状态的信号,需要被处理,处理之前需要和另一个信号集(阻塞信号集),进行比较
- 阻塞信号集默认不阻塞任何的信号
- 如果想要阻塞某些信号需要用户调用系统的API
- 在处理的时候和阻塞信号集中的标志位进行查询,看是不是对该信号设置阻塞了
- 如果没有阻塞,这个信号就被处理
- 如果阻塞了,这个信号就继续处于未决状态,直到阻塞解除,这个信号就被处理
SIGINT 信号
ctrl + c 产生的信号,2号信号
SIGQUIT 信号
ctrl + \ 产生的信号,3号信号
SIGKILL 信号
命令行 kill -9 或者 kill 函数 或者 raise函数 产生的信号
SIGABRT 信号
abort 函数产生的信号。
SIGALRM 信号
定时器倒计时为 0 后产生的信号。定时器有 alarm函数,setitimer函数
SIGCHLD 信号
当子进程终止、暂停、继续运行时,都会给父进程发送 SIGCHLD 信号,父进程会默认忽略该信号。
可以通过捕捉该信号修改接收到信号之后的处理方式,来解决 僵尸进程 的问题。原来的解决方案:在父进程中循环调用 wait() waitpid() 函数 来回收子进程 pcb 资源,并且 wait函数 是阻塞的,使得父进程没法做自己的事情;
SIGPIPE 信号
正常客户端调用 close() 是先断开自己的发送信息通道,还可以继续接收数据。但如果客户端不是正常的调用 close() 断开的连接,而是收发通道都断开了,此时服务器继续给客户端发送数据,就会产生 SIGPIPE信号。
共享内存
步骤:
- 用 shmget() 创建一个新共享内存段或取得一个既有共享内存段的标识符(即由其他进程创建的共享内存段)。这个调用将返回后续调用中需要用到的共享内存标识符。
- 使用 shmat() 来附上共享内存段,即使该段成为调用进程的虚拟内存的一部分。
此刻在程序中可以像对待其他可用内存那样对待这个共享内存段。为引用这块共享内存,程序需要使用由 shmat() - 调用返回的 addr 值,它是一个指向进程的虚拟地址空间中该共享内存段的起点的指针。
- 调用 shmdt() 来分离共享内存段。在这个调用之后,进程就无法再引用这块共享内存了。这一步是可选的,并且在进程终止时会自动完成这一步。
- 调用 shmctl() 来删除共享内存段。只有当当前所有附加内存段的进程都与之分离之后内存段才会销毁。只有一个进程需要执行这一步。
- 相关函数:
int shmget(key_t key, size_t size, int shmflg);
- 功能:创建一个新的共享内存段,或者获取一个既有的共享内存段的标识。
新创建的内存段中的数据都会被初始化为0
- 参数:
- key : key_t类型是一个整形,通过这个找到或者创建一个共享内存。
一般使用16进制表示,非0值
- size: 共享内存的大小
- shmflg: 属性
- 访问权限
- 附加属性:创建/判断共享内存是不是存在
- 创建:IPC_CREAT
- 判断共享内存是否存在: IPC_EXCL , 需要和IPC_CREAT一起使用
IPC_CREAT | IPC_EXCL | 0664
- 返回值:
失败:-1 并设置错误号
成功:>0 返回共享内存的引用的shmid,后面操作共享内存都是通过这个值。
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
key_t ftok(const char *pathname, int proj_id);
- 功能:根据指定的路径名,和int值,生成一个共享内存的key
- 参数:
- pathname:指定一个存在的路径
/home/nowcoder/Linux/a.txt
/
- proj_id: int类型的值,但是这系统调用只会使用其中的1个字节
范围 : 0-255 一般指定一个字符 'a'
问题
-
操作系统如何知道一块共享内存被多少个进程关联?
共享内存维护了一个结构体struct shmid_ds 这个结构体中有一个成员 shm_nattch, shm_nattach 记录了关联的进程个数 -
可不可以对共享内存进行多次删除 shmctl
- 可以, 因为shmctl 标记删除共享内存,不是直接删除
- 什么时候真正删除呢?
当和共享内存关联的进程数为0的时候,就真正被删除 - 当共享内存的key为0的时候,表示共享内存被标记删除了
如果一个进程和共享内存取消关联,那么这个进程就不能继续操作这个共享内存。也不能进行关联。
共享内存和内存映射的区别
- 共享内存可以直接创建,内存映射需要磁盘文件(匿名映射除外)
- 共享内存效率更高
- 内存
所有的进程操作的是同一块共享内存。
内存映射,每个进程在自己的虚拟地址空间中有一个独立的内存。 - 数据安全
进程突然退出:
共享内存还存在, 内存映射区消失
运行进程的电脑死机,宕机了
数据存在在共享内存中,没有了
内存映射区的数据 ,由于磁盘文件中的数据还在,所以内存映射区的数据还存在。 - 生命周期
内存映射区:进程退出,内存映射区销毁
共享内存:进程退出,共享内存还在,标记删除(所有的关联的进程数为0),或者关机
如果一个进程退出,会自动和共享内存进行取消关联。
守护进程
创建步骤
fork()
创建子进程,退出父进程,继续运行子进程- 将子进程提升为会话,即 子进程调用
setsid()
重新创建一个会话,脱离父进程所在的会话,从而脱离控制终端。 - 设置掩码
umask()
,清除进程的 umask 以确保当守护进程创建文件和目录时拥有所需的权限。 - 更改工作目录为根目录
chdir("/")
- 关闭守护进程从其父进程继承而来的所有打开着的文件描述符,在关闭了文件描述符0、1、2之后,守护进程通常会打开/dev/null 并使用
dup2()
使所有这些描述符指向这个设备。
int fd = open("dev/null", O_RDWR);
dup2(fd, STDIN_FILENO);
dup2(fd, STDOUT_FILENO);
dup2(fd, STDERR_FILENO);
- 业务逻辑
三.Linux多线程开发
从线程入手,什么是线程,线程的创建、终止、取消、连接、分离
到线程的属性的自定义(先定义线程的属性,在初始化线程时可以属性参数来初始化出有自定义属性的线程,比如初始化一个自动分离的线程);
再到多线程开发会遇到的问题以及处理方法:涉及到线程同步,避免数据混乱。可以用 互斥锁、读写锁来解决。
互斥锁的使用又可能会带来死锁的问题,只能人为干涉解决死锁。
讲到了 生产者消费者模型,只用上述的知识可以实现该模型,可以保证线程同步问题,保证数据安全。但因为该模型有边界的概念,消费者在商品数为 0 的情况下不可以继续销售,需要不断的判断是否有新的商品,需要一直消耗电脑资源来完成该事件。
所以后来又提出了 条件变量和信号量,来解决该问题。条件变量和信号量 可以让两者做到在不能继续生成或者不能继续销售时 阻塞,等待对方的通知,然后再去做自己的事件,从而节省资源。
1.线程知识
简介
与进程(process)类似,线程(thread)是允许应用程序并发执行多个任务的一种机制。一个进程可以包含多个线程。同一个程序中的所有线程均会独立执行相同程序,且共享同一份全局内存区域,其中包括初始化数据段、未初始化数据段,以及堆内存段。
进程是 CPU 分配资源的最小单位,线程是操作系统调度执行的最小单位。
线程是轻量级的进程(LWP:Light Weight Process),在 Linux 环境下线程的本质仍是进程。
查看指定进程的 LWP 号:ps –Lf pid
进程线程区别
进程间的信息难以共享。由于除去只读代码段外,父子进程并未共享内存,因此必须采用一些进程间通信方式,在进程间进行信息交换。
调用 fork() 来创建进程的代价相对较高,即便利用写时复制技术,仍然需要复制诸如内存页表和文件描述符表之类的多种进程属性,这意味着 fork() 调用在时间上的开销依然不菲。
线程之间能够方便、快速地共享信息。只需将数据复制到共享(全局或堆)变量中即可。创建线程比创建进程通常要快 10 倍甚至更多。线程间是共享虚拟地址空间的,无需采用写时复制来复制内存,也无需复制页表。
线程之间共享和非共享资源
共享资源
- 进程 ID 和父进程 ID
- 进程组 ID 和会话 ID
- 用户 ID 和 用户组 ID
- 文件描述符表
- 信号处置
- 文件系统的相关信息:文件权限掩码(umask)、当前工作目录
- 虚拟地址空间(除栈、.text)
- 非共享资源
线程 ID - 信号掩码
- 线程特有数据
- error 变量
- 实时调度策略和优先级
- 栈,本地变量和函数的调用链接信息
2.线程创建
进程的创建是读时共享,写时复制。复制虚拟地址空间,堆栈是不共享的;
但是线程的创建:虚拟地址空间是共享的,a进程创建出来的所有线程共享a 进程的虚拟地址空间(除.text段、栈空间),.text段、栈空间由每个线程使用进程对应区域的一部分;堆、共享库、内核区都是共享的。每个线程由自己的特有数据、阻塞信号掩码。
如图所示,.text段、栈空间由每个线程使用进程对应区域的一部分,其余共享
线程的创建、终止、连接、分离、取消
创建线程
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
- 功能:创建一个子线程
- 参数:
- thread:传出参数,线程创建成功后,子线程的线程ID被写到该变量中。
- attr : 设置线程的属性,一般使用默认值,NULL
- start_routine : 函数指针,这个函数是子线程需要处理的逻辑代码
!!!在c++中,这个必须是静态函数,c中是全局函数
- arg : 给第三个参数使用,传参
- 返回值:
成功:0
失败:返回错误号。这个错误号和之前errno不太一样。
获取错误号的信息: char * strerror(int errnum);
pthread_t pthread_self(void); 获取当前线程的 id
int pthread_equal(pthread_t t1, pthread_t t2);
功能:比较两个线程ID是否相等
不同的操作系统,pthread_t类型的实现不一样,有的是无符号的长整型,有的
是使用结构体去实现的。
终止线程
void pthread_exit(void *retval);
功能:终止一个线程,在哪个线程中调用,就表示终止哪个线程
参数:
retval:需要传递一个指针,作为一个返回值,可以在pthread_join()中获取到。 可为 null无返回值
terminate calling thread. 终止一个线程,哪个线程调用,就终止哪个线程。
并且主线程的退出不会影响其他线程的正常执行。
连接已终止的线程
int pthread_join(pthread_t thread, void **retval);
- 功能:和一个已经终止的线程进行连接
回收子线程的资源
这个函数是阻塞函数,调用一次只能回收一个子线程
一般在主线程中使用
- 参数:
- thread:需要回收的子线程的ID
- retval: 接收子线程退出时的返回值
- 返回值:
0 : 成功
非0 : 失败,返回的错误号
是一个阻塞函数,调用一次只能回收一个子线程,和 wait() 很像。不能去连接一个已经分离的线程。
线程分离
int pthread_detach(pthread_t thread);
- 功能:分离一个线程。被分离的线程在终止的时候,会自动释放资源返回给系统。
1.不能多次分离,会产生不可预料的行为。
2.不能去连接一个已经分离的线程,会报错。
- 参数:需要分离的线程的ID
- 返回值:
成功:0
失败:返回错误号
线程取消
int pthread_cancel(pthread_t thread);
- 功能:取消线程(让线程终止)
取消某个线程,可以终止某个线程的运行,
但是并不是立马终止,而是当子线程执行到一个取消点,线程才会终止。
取消点:系统规定好的一些系统调用,我们可以粗略的理解为从用户区到内核区的切换,这个位置称之为取消点。
线程属性
int pthread_attr_init(pthread_attr_t *attr);
- 初始化线程属性变量
int pthread_attr_destroy(pthread_attr_t *attr);
- 释放线程属性的资源
int pthread_attr_getdetachstate(const pthread_attr_t *attr, int *detachstate);
- 获取线程分离的状态属性
int pthread_attr_setdetachstate(pthread_attr_t *attr, int detachstate);
- 设置线程分离的状态属性
创建线程的时候有一个参数是线程的属性相关,我们可以先设置好想要创建的线程的属性,然后传参创建这样的线程。
属性要先 初始化,最后线程属性资源需要被 销毁,还有一些 获取和设置属性的函数。
pthread_attr_t attr; // 属性类型
pthread_attr_init(&attr);
// 比如 设置分离线程属性
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
// 创建进程的时候把属性参数指定为自己定义的
int ret = pthread_create(&tid, &attr, callback, NULL);
pthread_attr_destroy(&attr);
pthread_exit(NULL);
3.线程同步
线程同步
线程的优势是可以通过全局变量共享资源。那换个角度,也正因为共享全局区资源,就可能会出现一些问题。如果读写不当的话。比如说线程A正在对 临界资源 读操作,此时线程B 也可以操作到该 临界资源 对其进行了写操作,就会发生时间片在回到A线程后读到的数据发生了变化。
临界区 是指访问某一共享资源的代码片段,临界区的代码片段的执行应该是 原子操作 的,也就是说同时访问临界区资源的其他线程不能中断当前正在执行这段代码的线程。
线程同步
当有一个线程在对内存操作时,其他线程都不可以对这个内存地址进行操作。直到该线程完成操作,其他线程才能对该内存地址进程操作,而其他线程则处于等待状态。
不同的线程在执行各自的代码区的代码和操作属于自己栈区空间的数据时属于并行操作;但操作临界区资源的时候就属于串行操作了,此时就涉及的是并发的问题了。对于临界区资源的执行要保证其原子性。
互斥锁/互斥量
为了避免线程更新共享变量时出现问题,可以使用 互斥量(mutex, mutual exclusion),来确保同一时间只有一个线程可以访问某项共享资源,保证对任意共享资源的原子访问。
互斥量有两种状态:已锁定 和 未锁定。任意时候,最后只能有一个线程锁定某项共享资源。一旦线程锁定互斥量,随机成为互斥量的所有者,只有所有者才能给互斥量解锁。
使用流程为:
- 针对共享资源 锁定互斥量
- 访问共享资源
- 对互斥量解锁
相关函数
互斥量的类型 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);
- 解锁
死锁
死锁是在互斥锁的应用过程中加解锁操作不当导致的。
有时,一个线程需要同时访问两个或更多不同的共享资源,而每个资源又都由不同的互斥量管理。当超过一个线程加锁同一组互斥量时,就有可能发生死锁。
两个或两个以上的进程在执行过程中,因争夺共享资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁。
产生死锁的几种场景:
- 加锁访问完数据后,忘记释放锁
- 一个线程中对一个共享资源 重复加同一把锁
- 两个资源,分别锁定了一个资源,又需要访问另一个资源才能完成操作。
读写锁
当有一个线程已经持有互斥锁时,互斥锁将所有试图进入临界区的线程都阻塞住。但是考虑一种情形,当前持有互斥锁的线程只是要读访问共享资源,而同时有其它几个线程也想读取这个共享资源,但是由于互斥锁的排它性,所有其它线程都无法获取锁,也就无法读访问共享资源了,但是实际上多个线程同时读访问共享资源并不会导致问题。
在对数据的读写操作中,更多的是读操作,写操作较少,例如对数据库数据的读写应用。为了满足当前能够允许多个读出,但只允许一个写入的需求,线程提供了读写锁来实现。
读写锁的特点:
- 如果有其它线程读数据,则允许其它线程执行读操作,但不允许写操作。
- 如果有其它线程写数据,则其它线程都不允许读、写操作。
- 写是独占的,写的优先级高。
相关函数
读写锁的类型 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);解锁读写锁
生产者消费者模型
条件变量
满足某一条件后 使线程阻塞或解除线程阻塞。不是锁,是配合锁来实现数据同步的问题的。
条件变量的类型 pthread_cond_t
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
int pthread_cond_destroy(pthread_cond_t *cond);
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
- 等待,调用了该函数,线程会阻塞。等待信号或者广播来解除阻塞。
- 参数有互斥锁,但这个互斥锁并不会一直拿着,而是会在当前线程阻塞后 先解锁,使得其他线程可以抢占该锁,当接到信号后再将锁加到自己身上
int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const struct timespec *restrict abstime);
- 等待多长时间,调用了这个函数,线程会阻塞,直到指定的时间结束。
int pthread_cond_signal(pthread_cond_t *cond);
- 发送信号给 wait() 的线程,最少一个,最多全部线程解除阻塞。将一个或多个线程唤醒
int pthread_cond_broadcast(pthread_cond_t *cond);
- 唤醒所有的等待的线程
信号量
主要作用是 阻塞线程,不能保证线程的数据安全问题;如果要保证数据安全,一定要和互斥锁一块使用。
我们在创建了全局变量信号量后,需要先在 main线程中初始化才可以使用,
初始化有一个参数是value,我们可以理解为是生产者可以生成的最大个数/消费者最多可以卖出的个数;
也就是说同样一个容器,两者关注的侧重点是不同的,生产者关注还有多少空位置可以生产
有空位生产者工作 无空位生产者阻塞 等待消费者出售商品腾出空位再生产,
而消费者关注的是有多少个蛋糕可以出售 有的卖则消费者工作 没有则阻塞 等待生产者生产 有了实体之后再去销售。
两者通过对容器中的空位和商品个数的 wait 和 post 进行通信,从而避免一方不断的判断是否可以继续生产或者继续销售。
信号量的类型 sem_t
int sem_init(sem_t *sem, int pshared, unsigned int value);
- 初始化信号量
- 参数:
- sem : 信号量变量的地址
- pshared : 0 用在线程间 ,非0 用在进程间
- value : 信号量中的值
- 通过信号量数量的加加减减(post和wait)来通知两个线程,将两个线程联系起来。可以理解为容器的容量。
int sem_destroy(sem_t *sem);
- 释放资源
int sem_wait(sem_t *sem);
- 对信号量加锁,调用一次对信号量的值-1,如果值为0,就阻塞
int sem_trywait(sem_t *sem);
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);
int sem_post(sem_t *sem);
- 对信号量解锁,调用一次对信号量的值+1
int sem_getvalue(sem_t *sem, int *sval);
sem_t psem;
sem_t csem;
init(psem, 0, 8); //生产者刚开始的数量是 8,需要不断--
init(csem, 0, 0); //消费者刚开始的数量是 0,需要不断++
producer() {
sem_wait(&psem);
sem_post(&csem)
}
customer() {
sem_wait(&csem);
sem_post(&psem)
}
四、Linux网络编程
1.基本知识
网卡 与 MAC地址
网卡是一个硬件,使得计算机可以在网络上进行通信的设备,又称为网络适配器 或 网络接口卡NIC。有以太网卡和无线网卡。
每个网卡有唯一的 mac地址作为标识,mac地址是一个独一无二的48位串行号。MAC地址 是由48位(6字节)组成的,通常表示为 12 个 16 进制数。
网卡的主要功能:
- 数据的封装与解封;
- 数据链路管理;
- 数据编码与译码。
IP地址,IP协议
IP协议
是为计算机间连接通信而设计的协议,是一套可以使连接到网上的所有计算机实现相互通信的规则。
ip地址
全称 Internet protocol address,是指互联网协议地址。 是IP协议分配的一个逻辑地址,提供统一的地址格式,以此来屏蔽物理地址的差异。
IP 地址是一个 32 位的二进制数,通常被分割为 4 个“ 8 位二进制数”(也就是 4 个字节)。IP 地址通常用“点分十进制”表示成(a.b.c.d)的形式,其中,a,b,c,d都是 0~255 之间的十进制整数。例:点分十进IP地址(100.4.5.6),实际上是 32 位二进制数
IP地址 由 网络id和主机id 组成,可以通过 子网掩码 来区分。分为 A类IP地址,B类IP地址,C类IP地址;分别适用于大规模、中等规模、小规模的网络。
每一个字节都为 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地址
端口
端口是设备与外界通讯交流的出口;如果把 IP地址 比作是一个房间,端口就是出入这间房的门。端口通过端口号来标识,端口的个数是 2^16 65536个,从 0-65535.
端口可以分为虚拟端口和物理端口。
虚拟端口只是逻辑意义上的端口,不可见,特指 TCP/IP协议 中的端口,如计算机中的 80端口、22端口等。
物理端口是可见端口,如计算机 交换机 路由器内的 RJ45网口。
端口又分为 周知端口 和 注册端口 两种类型。
周知端口就是大家公认的端口,范围从0-1023。比如我们熟知的 80端口分配给 www服务,21端口分配给 FTP服务等。
剩余的端口 分配给用户进程或应用程序使用。
网络模型
OSI 七层参考模型
- 物理层:定义物理设备标准,如接口类型、介质传输速率等。主要作用是传输比特流,该层的数据叫做 比特。(就是由1、0转化为电流强弱来进行传输,到达目的地后再转化为1、0,也就是我们常说的数模转换与模数转换)
- 数据链路层:网卡起作用的层。逻辑连接、硬件地址寻址、差错校验等功能。将比特组合成字节,进而组合成 帧,用mac地址访问介质。
- 网络层:IP协议起作用的层。逻辑地址寻址,为不同位置网络中的主机之间提供连接和路径选择。
- 传输层:定义了一些传输数据的协议和端口号,进行数据传输。将下层接受到的数据进行分段和传输,到达目的地后再进行重组。 这一层的数据叫做 段。
- 会话层:通过传输层建立数据传输的通路,发起会话或接受会话请求。
- 表示层:将接收到的信息经过转换,将计算机能够识别的东西表示成用户可以识别的内容。对数据进行解释、加密解密、压缩解压缩。
- 应用层:网络服务和用户的一个接口,为用户的应用程序提供网络服务。
TCP/IP 四层模型
- 应用层:应用层是 TCP/IP 协议的第一层,是直接为应用进程提供服务的。
(1)对不同种类的应用程序它们会根据自己的需要来使用应用层的不同协议,邮件传输应用使用了 SMTP 协议、万维网应用使用了 HTTP 协议、远程登录服务应用使用了有 TELNET 协议。
(2)应用层还能加密、解密、格式化数据。
(3)应用层可以建立或解除与其他节点的联系,这样可以充分节省网络资源。 - 传输层:位于第二层,在运输层中, TCP 和 UDP 起到了中流砥柱的作用。
- 网络层:位于第三层。在 TCP/IP 协议中网络层可以进行网络连接的建立和终止以及 IP 地址的寻找等功能。
- 网络接口层:位于第四层。由于网络接口层兼并了物理层和数据链路层,所以,网络接口层既是传输数据的物理媒介,也可以为网络层提供一条准确无误的线路。
协议
常见协议
- 应用层常见的协议有:FTP协议(File Transfer Protocol 文件传输协议)、HTTP协议(Hyper Text Transfer Protocol 超文本传输协议)、NFS(Network File System 网络文件系统)。
- 传输层常见协议有:TCP协议(Transmission Control Protocol 传输控制协议)、UDP协议(User Datagram Protocol 用户数据报协议)。
- 网络层常见协议有:IP 协议(Internet Protocol 因特网互联协议)、ICMP 协议(Internet Control Message Protocol 因特网控制报文协议)、IGMP 协议(Internet Group Management Protocol 因特网组管理协议)。
- 网络接口层常见协议有:ARP协议(Address Resolution Protocol 地址解析协议)、RARP协议(Reverse Address Resolution Protocol 反向地址解析协议)。
UDP协议
- 源端口号:发送方端口号
- 目的端口号:接收方端口号
- 长度:UDP用户数据报的长度,最小值是8(仅有首部)
- 校验和:检测UDP用户数据报在传输中是否有错,有错就丢弃
TCP协议
- 源端口号:发送方端口号
- 目的端口号:接收方端口号
- 序列号:本报文段的数据的第一个字节的序号
- 确认序号:期望收到对方下一个报文段的第一个数据字节的序号
- 首部长度(数据偏移):TCP 报文段的数据起始处距离 TCP 报文段的起始处有多远,即首部长度。单位:32位,即以 4 字节为计算单位
- 保留:占 6 位,保留为今后使用,目前应置为 0
- 紧急 URG :此位置 1 ,表明紧急指针字段有效,它告诉系统此报文段中有紧急数据,应尽快传送
- 确认 ACK:仅当 ACK=1 时确认号字段才有效,TCP 规定,在连接建立后所有传达的报文段都必须把 ACK 置1
- 推送 PSH:当两个应用进程进行交互式的通信时,有时在一端的应用进程希望在键入一个命令后立即就能够收到对方的响应。在这种情况下,TCP 就可以使用推送(push)操作,这时,发送方 TCP 把 PSH 置 1,并立即创建一个报文段发送出去,接收方收到 PSH = 1 的报文段,就尽快地(即“推送”向前)交付给接收应用进程,而不再等到整个缓存都填满后再向上交付
- 复位 RST:用于复位相应的 TCP 连接
- 同步 SYN:仅在三次握手建立 TCP 连接时有效。当 SYN = 1 而 ACK = 0 时,表明这是一个连接请求报文段,对方若同意建立连接,则应在相应的报文段中使用 SYN = 1 和 ACK = 1。因此,SYN 置 1 就表示这是一个连接请求或连接接受报文
- 终止 FIN:用来释放一个连接。当 FIN = 1 时,表明此报文段的发送方的数据已经发送完毕,并要求释放运输连接
- 窗口:指发送本报文段的一方的接收窗口(而不是自己的发送窗口)
- 校验和:校验和字段检验的范围包括首部和数据两部分,在计算校验和时需要加上 12 字节的伪头部
- 紧急指针:仅在 URG = 1 时才有意义,它指出本报文段中的紧急数据的字节数(紧急数据结束后就是普通数据),即指出了紧急数据的末尾在报文中的位置,注意:即使窗口为零时也可发送紧急数据
- 选项:长度可变,最长可达 40 字节,当没有使用选项时,TCP 首部长度是 20 字节
IP协议
- 版本:IP 协议的版本。通信双方使用过的 IP 协议的版本必须一致,目前最广泛使用的 IP 协议版本号为 4(即IPv4)
- 首部长度:单位是 32 位(4 字节)
- 服务类型:一般不适用,取值为 0
- 总长度:指首部加上数据的总长度,单位为字节
- 标识(identification):IP 软件在存储器中维持一个计数器,每产生一个数据报,计数器就加 1,
并将此值赋给标识字段 - 标志(flag):目前只有两位有意义。
标志字段中的最低位记为 MF。MF = 1 即表示后面“还有分片”的数据报。MF = 0 表示这已是若干数据报片中的最后一个。
标志字段中间的一位记为 DF,意思是“不能分片”,只有当 DF = 0 时才允许分片 - 片偏移:指出较长的分组在分片后,某片在源分组中的相对位置,也就是说,相对于用户数据段的起点,该片从何处开始。片偏移以 8 字节为偏移单位。
- 生存时间:TTL,表明是数据报在网络中的寿命,即为“跳数限制”,由发出数据报的源点设置这个字段。路由器在转发数据之前就把 TTL 值减一,当 TTL 值减为零时,就丢弃这个数据报。
- 协议:指出此数据报携带的数据时使用何种协议,以便使目的主机的 IP 层知道应将数据部分上交给哪个处理过程,常用的 ICMP(1),IGMP(2),TCP(6),UDP(17),IPv6(41)
- 首部校验和:只校验数据报的首部,不包括数据部分。
- 源地址:发送方 IP 地址
- 目的地址:接收方 IP 地址
以太网帧协议
类型:0x800表示 IP、0x806表示 ARP、0x835表示 RARP
ARP协议
- 硬件类型:1 表示 MAC 地址
- 协议类型:0x800 表示 IP 地址
- 硬件地址长度:6
- 协议地址长度:4
- 操作:1 表示 ARP 请求,2 表示 ARP 应答,3 表示 RARP 请求,4 表示 RARP 应答
网络通信过程
封装
上层协议是如何使用下层协议提供的服务的呢?其实这是通过封装(encapsulation)实现的。应用程序数据在发送到物理网络上之前,将沿着协议栈从上往下依次传递。每层协议都将在上层数据的基础上加上自己的头部信息(有时还包括尾部信息),以实现该层的功能,这个过程就称为封装
分用
当帧到达目的主机时,将沿着协议栈自底向上依次传递。各层协议依次处理帧中本层负责的头部数据,以获取所需的信息,并最终将处理后的帧交给目标应用程序。这个过程称为分用(demultiplexing)。分用是依靠头部信息中的类型字段实现的。
网络通信过程
以传输一条 QQ消息 为例:
- 发送方从应用层开始,经过传输层、网络层、数据链路层,在每一层都会根据所在层所采用的通信协议加对应的消息头和消息尾;
- 发送到接收方后,接收方反向从下层依次向上将信息传递上去,每一层去解析对应的头部信息和尾部信息,然后根据信息发送给对应的上一层,最后到达应用层,应用层根据应用层头解析得到应用数据。通信完成。
ARP协议通过 IP地址查询mac地址,需要 硬件类型即mac地址、协议类型即谁发来的ip地址、硬件地址长度6个字节的物理地址长度、协议地址长度、操作是ARP应答还是请求、发送端以太网地址、发送方IP地址、目的端以太网地址、目的端IP地址。
字节序
大端序小端序
大端序 (网络字节序)高位字节存入低地址
小端序(主机字节序)低位字节存入低地址
网络字节顺序:采用大端排序方式,是 TCP/IP 中规定好的一种数据表示格式。与具体的 CPU类型、操作系统等无关,从而保证数据在不同的主机之间传输时能够被正确解释。
字节序转换函数
当格式化的数据在两台字节序不同的主机间直接传递时,接收端必然错误的解释接收到的数据。
解决办法是:发送端总是要把发送的数据转换成大端字节序数据后再发送,即网络字节序都是一样的,为大端字节序。然后接收端通过判断自己采用的字节序来决定是否需要对接收到的数据进行转换,大端机不转换,小端机则需要转换后再对其进行解释。
网络通信时,需要将主机字节序转换成网络字节序(大端),
另外一段获取到数据以后根据情况将网络字节序转换成主机字节序。
h - host 主机,主机字节序
to - 转换成什么
n - network 网络字节序
s - short unsigned short
l - long unsigned int
// 转换端口
uint16_t htons(uint16_t hostshort); // 主机字节序 - 网络字节序
uint16_t ntohs(uint16_t netshort); // 主机字节序 - 网络字节序
// 转IP
uint32_t htonl(uint32_t hostlong); // 主机字节序 - 网络字节序
uint32_t ntohl(uint32_t netlong); // 主机字节序 - 网络字节序
2.网络编程相关函数
socket 地址
socket 地址其实是一个结构体,封装端口号和IP 等信息。socket 相关的 api 中需要用到这个 socket地址。
所有的 socket编程接口 使用的地址参数类型都是 sockaddr
通用 socket 地址:
只是留下了存放 IP和port 的内存空间,还需要自己写入,不方便。用专用的更方便。 最初的通用 socket地址是结构体 sockaddr:
#include <bits/socket.h>
struct sockaddr {
sa_family_t sa_family; //决定协议类型的协议族。有:PF_UNIX, PF_INET, PF_INET6
char sa_data[14]; // 根据协议族类型决定存放 socket地址值。
};
typedef unsigned short int sa_family_t;
协议族 | 地址族 | 地址值含义和长度 |
---|---|---|
PF_UNIX | AF_UNIX | 文件的路径名,长度可达到108字节 |
PF_INET | AF_INET | 16 bit 端口号和 32 bit IPv4 地址,共 6 字节 |
PF_INET6 | AF_INET6 | 16 bit 端口号,32 bit 流标识,128 bit IPv6 地址,32 bit 范围 ID,共 26 字节 |
可以发现该通用类型存放 ipv6的socket 地址值并不够,所以后来又提出了新的 socket地址结构体 sockaddr_storage。有了更大的存储空间来存放协议地址值,并且是内存对齐的。
专用 socket 地址,常用,方便
已经对应不同的地址协议 封装好了ip和port,直接用即可。专门用于存放 ipv4 socket地址值的结构体 sockaddr_in, 和 专门存放 ipv6 地址值的结构体 sockaddr_in6
#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
IP地址转换
IP地址的 字符串表示到整数表示的转换;主机字节序和网络字节序的转换。
我们阅读时习惯用 点分十进制 表示,然后计算机处理的是二进制数,所以我们会进行字符串和整数之间的转换,所以需要掌握一些 用点分十进制字符串表示的 ip地址 到 用网络字节序整数表示的 IP地址 转到的函数:
#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 是一样的
套接字函数
IP地址的 字符串表示到整数表示的转换;主机字节序和网络字节序的转换。
我们阅读时习惯用 点分十进制 表示,然后计算机处理的是二进制数,所以我们会进行字符串和整数之间的转换,所以需要掌握一些 用点分十进制字符串表示的 ip地址 到 用网络字节序整数表示的 IP地址 转到的函数:
#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 : 流式协议
SOCK_DGRAM : 报式协议
- protocol : 具体的一个协议。一般写0
- SOCK_STREAM : 流式协议默认使用 TCP
- SOCK_DGRAM : 报式协议默认使用 UDP
- 返回值:
- 成功:返回文件描述符,操作的就是内核缓冲区。
- 失败:-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
- 功能:监听这个socket上的连接
- 参数:
- sockfd : 通过socket()函数得到的文件描述符
- backlog : 未连接的和已经连接的和的最大值, 5
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); // 读数据
3. TCP与UDP
TCP UDP 比较
TCP 和 UDP都是传输层的协议
UDP:用户数据报协议,面向无连接,可以单播,多播,广播, 面向数据报,不可靠
TCP:传输控制协议,面向连接的,可靠的,基于字节流,仅支持单播传输
\ | UDP | TCP |
---|---|---|
是否创建连接 | 无连接 | 面向连接 |
是否可靠 | 不可靠 | 可靠的 |
连接的对象个数 | 一对一、一对多、多对一、多对多 | 支持一对一 |
传输的方式 | 面向数据报 | 面向字节流 |
首部开销 | 8个字节 | 最少20个字节 |
适用场景 | 实时应用(视频会议,直播) | 可靠性高的应用(文件传输) |
4. TCP通信
TCP通信流程
TCP 通信的流程
- 服务器端 (被动接受连接的角色)
- 创建一个用于监听的套接字
- 监听:监听有客户端的连接
- 套接字:这个套接字其实就是一个文件描述符
- 将这个监听文件描述符和本地的IP和端口绑定(IP和端口就是服务器的地址信息)
- 客户端连接服务器的时候使用的就是这个IP和端口
- 设置监听,监听的fd开始工作
- 阻塞等待,当有客户端发起连接,解除阻塞,接受客户端的连接,会得到一个和客户端通信的套接字(fd)
- 通信
- 接收数据
- 发送数据
- 通信结束,断开连接
- 创建一个用于监听的套接字
- 客户端
- 创建一个用于通信的套接字(fd)
- 连接服务器,需要指定连接的服务器的 IP 和 端口
- 连接成功了,客户端可以直接和服务器通信
- 接收数据
- 发送数据
- 通信结束,断开连接
TCP三次握手
TCP 可以看成是一种字节流,它会处理 IP 层或以下的层的丢包、重复以及错误问题。在连接的建立过程中,双方需要交换一些连接的参数。这些参数可以放在 TCP 头部。
三次握手主要是为了保证通信双方之间建立起连接。发生在客户端连接到服务器端的时候,使用 socket通信 调用 connect() 时,底层会通过 TCP协议 进行三次握手
tcp 头部结构 中包括 源端口和目标端口号共4个字节、4个字节 序号,4个字节 确认号,几个标志位,两个字节 窗口大小,16位 校验和,16位紧急指针以及40字节的备选。
重点关注:32位序号,32位确认号,几个标志位(ACK, SYN, FIN),16位窗口大小
三次握手流程:
- 第一次握手:
- 客户端将SYN标志位置为1
- 生成一个随机的32位的序号seq=J , 这个序号后边是可以携带数据(数据的大小)
- 第二次握手:
- 服务器端接收客户端的连接: ACK=1
- 服务器会回发一个确认序号: ack=客户端的序号 + 数据长度 + SYN/FIN(按一个字节算)
- 服务器端会向客户端发起连接请求: SYN=1
- 服务器会生成一个随机序号:seq = K
- 第三次握手:
- 客户单应答服务器的连接请求:ACK=1
- 客户端回复收到了服务器端的数据:ack=服务端的序号 + 数据长度 + SYN/FIN(按一个字节算)
- 注:只有SYN和FIN为1的时候,ack才加1
从这样两个方面来 考虑和理解三次握手
- 通信过程中的 标志位的变化
连接过程中起作用的标志位有: SYN = 1发起连接,ACK = 1确认收到连接请求 - 通信过程中 序号和确认号的变化
序号:tcp通信是面向字节流的,字节流数据中的每个字节会通过序号作唯一标识,序号是根据一定的规则随机生成的,并不是每次都是固定的从0开始。
确认序号:接收到对应序号的字节流后,返回一个确认序号,为接收到的所有字节流最后一个字节的下一个字节对应的序号即为确认号。
TCP滑动窗口
滑动窗口是一种流量控制技术。是TCP 中实现像 ACK确认、流量控制、拥塞控制的承载结构。重点在接收方的接收缓冲区,通过接收方的处理能力来决定滑动窗口的大小。
滑动窗口的大小意味着接收方还有多大的缓冲区可以用于接收数据。发送方可以通过滑动窗口的大小来决定应该发送多少字节的数据(流量控制)。其大小会随着发送数据和接收数据而变化。
当滑动窗口的大小为 0 时,发送方会停止给接收方发送数据,阻塞等待接收方可以继续接收数据。(拥塞控制)
当通信双方建立连接后,开始收发数据。收发数据不是单纯的根据自己的发送能力来发送数据,而是根据接收方的接收能力来动态调整发送数据的量。发送方不是没发一条报文就会阻塞等待接收方的确认信息,而是根据接收方剩余缓冲区大小 连续发送报文,直到发送出去的数据够填充接收方缓冲区的大小后,阻塞等待接收方处理重新回复给自己非0的滑动窗口,再继续发送数据。
- 发送方的缓冲区:
白色格子:空闲的空间
灰色格子:数据已经被发送出去了,但是还没有被接收
紫色格子:还没有发送出去的数据 - 接收方的缓冲区:
白色格子:空闲的空间
紫色格子:已经接收到的数据
mss: Maximum Segment Size(一条数据的最大的数据量)
win: 滑动窗口
- 客户端向服务器发起连接,客户单的滑动窗口是4096,一次发送的最大数据量是1460
- 服务器接收连接情况,告诉客户端服务器的窗口大小是6144,一次发送的最大数据量是1024
- 第三次握手
- 4-9 客户端连续给服务器发送了6k的数据,每次发送1k
- 第10次,服务器告诉客户端:发送的6k数据以及接收到,存储在缓冲区中,缓冲区数据已经处理了2k,窗口大小是2k
- 第11次,服务器告诉客户端:发送的6k数据以及接收到,存储在缓冲区中,缓冲区数据已经处理了4k,窗口大小是4k
- 第12次,客户端给服务器发送了1k的数据
- 第13次,客户端主动请求和服务器断开连接,并且给服务器发送了1k的数据
- 第14次,服务器回复ACK 8194, a:同意断开连接的请求 b:告诉客户端已经接受到方才发的2k的数据 c:滑动窗口2k
- 第15、16次,通知客户端滑动窗口的大小
- 第17次,第三次挥手,服务器端给客户端发送FIN,请求断开连接
- 第18次,第四次回收,客户端同意了服务器端的断开请求
TCP四次挥手
四次挥手发生在断开连接的时候,在程序中调用 close() 会使用 TCP协议进行四次挥手。
客户端和服务器端都可以主动发起断开连接,谁先调用 close() 谁主动发起。
以客户端主动断开为例:
- 客户端
- 调用 close() 发起断开连接的请求,发送 FIN=1的报文段给服务器,状态由 established > fin_wait_1.
- 接收到服务器端的 ACK=1后,状态变为 fin_wait_2
- 接收到服务器端的断开连接请求后,状态变为 time_wait,此时当发送 ACK=1 的报文段给服务器,会等待 2msl(两倍的最大报文段生命周期时长),确保最后一次发送的数据到达了服务器。待等待 2msl后,变为 close状态。
- 服务器:
- 接收到客户端的断开连接请求后,状态变为 close_wait,并且发送 ACK=1给客户端。
- 处理完最后的业务后,调用 close()函数,发送断开连接请求 FIN=1 给客户端,状态变为 LAST_ACK。
- 待收到最后的确认标志后 ACK=1后,变为 established
TCP状态转换
2MSL(Maximum Segment Lifetime)
主动断开连接的一方, 最后进入一个 TIME_WAIT状态, 这个状态会持续: 2msl
msl: 官方建议: 2分钟, 实际是30s
当 TCP 连接主动关闭方接收到被动关闭方发送的 FIN 和最终的 ACK 后,连接的主动关闭方必须处于TIME_WAIT 状态并持续 2MSL 时间。这样就能够让 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。
shutdown 和 close 的区别
使用 close 终止一个连接,只是减少描述符的引用计数,并不直接关闭连接,只有当描述符的引用计数为 0 时才关闭连接。
shutdown 并不考虑描述符的引用计数,而是直接关闭描述符,当然 可以选择只终止读、只终止写或读写全部终止。也就是说如果一个描述符被多个进程打开着,只要有一个进程调用了 shutdown,其他进程也将无法进行通信;但调用 close() 不会影响到其他进程。
注意:
- 如果有多个进程共享一个套接字,close 每被调用一次,计数减 1 ,直到计数为 0 时,也就是所用进程都调用了 close,套接字将被释放。
- 在多进程中如果一个进程调用了 shutdown(sfd, SHUT_RDWR) 后,其它的进程将无法进行通信。但如果一个进程 close(sfd) 将不会影响到其它进程。
并发服务器的实现
要实现TCP通信服务器处理并发的任务,使用多线程或者多进程来解决。
思路:
- 一个父进程,多个子进程
- 父进程负责等待并接受客户端的连接
- 子进程:完成通信,接受一个客户端连接,就创建一个子进程用于通信。
多进程实现
多进程实现并发服务器的开发,需要用到 fork() 来创建子进程,创建了子进程就需要在子进程执行完事务后 回收子进程资源(wait(), waitpid(), 信号捕捉)。
wait() 的处理方式会使父进程阻塞,不能去处理自己的事务,这里指的是不能不断的 accept() 新的客户端的连接。
信号捕捉 会导致 accept() 的软中断,也是一种错误,所以需要判断的时候对这种错误进行特殊处理,使其不会影响到 accept() 的阻塞。
若干个客户端已经和服务器端建立了连接 并且已经在通信,此时如果有客户端断开连接,则客户端不在给服务器写数据,服务器最后一次读取数据长度为0。可是其实此时走的是 返回值为-1的语句体,返回一个 read:Connection reset by peer 错误,所以断开连接后要break。
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <wait.h>
#include <errno.h>
void recyleChild(int arg) {
while(1) {
int ret = waitpid(-1, NULL, WNOHANG);
if(ret == -1) {
// 所有的子进程都回收了
break;
}else if(ret == 0) {
// 还有子进程活着
break;
} else if(ret > 0){
// 被回收了
printf("子进程 %d 被回收了\n", ret);
}
}
}
int main() {
struct sigaction act;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
act.sa_handler = recyleChild;
// 注册信号捕捉
sigaction(SIGCHLD, &act, NULL);
// 创建socket
int lfd = socket(PF_INET, SOCK_STREAM, 0);
if(lfd == -1){
perror("socket");
exit(-1);
}
struct sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_port = htons(9999);
saddr.sin_addr.s_addr = INADDR_ANY;
// 绑定
int ret = bind(lfd,(struct sockaddr *)&saddr, sizeof(saddr));
if(ret == -1) {
perror("bind");
exit(-1);
}
// 监听
ret = listen(lfd, 128);
if(ret == -1) {
perror("listen");
exit(-1);
}
// 不断循环等待客户端连接
while(1) {
struct sockaddr_in cliaddr;
int len = sizeof(cliaddr);
// 接受连接
int cfd = accept(lfd, (struct sockaddr*)&cliaddr, &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, &cliaddr.sin_addr.s_addr, cliIp, sizeof(cliIp));
unsigned short cliPort = ntohs(cliaddr.sin_port);
printf("client ip is : %s, port is %d\n", cliIp, cliPort);
// 接收客户端发来的数据
char recvBuf[1024];
while(1) {
int len = read(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(cfd, recvBuf, strlen(recvBuf) + 1);
}
close(cfd);
exit(0); // 退出当前子进程
}
}
close(lfd);
return 0;
}
多线程实现
这个就考虑一个点,就是多线程在实现并发服务器时,因为每个线程的栈区和代码区是独立的,所以线程的数量需要有一个上限,而不能每个客户端连接进来就创建一个线程这么简单。而是定义一个数组专门用来负责连接到客户端负责通信。
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
struct sockInfo {
int fd; // 通信的文件描述符
struct sockaddr_in addr;
pthread_t tid; // 线程号
};
struct sockInfo sockinfos[128];
void * working(void * arg) {
// 子线程和客户端通信 cfd 客户端的信息 线程号
// 获取客户端的信息
struct sockInfo * pinfo = (struct sockInfo *)arg;
char cliIp[16];
inet_ntop(AF_INET, &pinfo->addr.sin_addr.s_addr, cliIp, sizeof(cliIp));
unsigned short cliPort = ntohs(pinfo->addr.sin_port);
printf("client ip is : %s, prot is %d\n", cliIp, cliPort);
// 接收客户端发来的数据
char recvBuf[1024];
while(1) {
int len = read(pinfo->fd, &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->fd, recvBuf, strlen(recvBuf) + 1);
}
close(pinfo->fd);
return NULL;
}
int main() {
// 创建socket
int lfd = socket(PF_INET, SOCK_STREAM, 0);
if(lfd == -1){
perror("socket");
exit(-1);
}
struct sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_port = htons(9999);
saddr.sin_addr.s_addr = INADDR_ANY;
// 绑定
int ret = bind(lfd,(struct sockaddr *)&saddr, sizeof(saddr));
if(ret == -1) {
perror("bind");
exit(-1);
}
// 监听
ret = listen(lfd, 128);
if(ret == -1) {
perror("listen");
exit(-1);
}
// 初始化数据
int max = sizeof(sockinfos) / sizeof(sockinfos[0]);
for(int i = 0; i < max; i++) {
bzero(&sockinfos[i], sizeof(sockinfos[i]));
sockinfos[i].fd = -1;
sockinfos[i].tid = -1;
}
// 循环等待客户端连接,一旦一个客户端连接进来,就创建一个子线程进行通信
while(1) {
struct sockaddr_in cliaddr;
int len = sizeof(cliaddr);
// 接受连接
int cfd = accept(lfd, (struct sockaddr*)&cliaddr, &len);
struct sockInfo * pinfo;
for(int i = 0; i < max; i++) {
// 从这个数组中找到一个可以用的sockInfo元素
if(sockinfos[i].fd == -1) {
pinfo = &sockinfos[i];
break;
}
if(i == max - 1) {
sleep(1);
// i--;
i = -1;
}
}
pinfo->fd = cfd;
memcpy(&pinfo->addr, &cliaddr, len);
// 创建子线程
pthread_create(&pinfo->tid, NULL, working, pinfo);
pthread_detach(pinfo->tid);
}
close(lfd);
return 0;
}
客户端
// TCP通信的客户端
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
int main() {
// 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, "172.26.206.186", &serveraddr.sin_addr.s_addr);
serveraddr.sin_port = htons(9999);
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) {
sprintf(recvBuf, "data : %d\n", i++);
// 给服务器端发送数据
write(fd, recvBuf, strlen(recvBuf)+1);
int len = read(fd, recvBuf, sizeof(recvBuf));
if(len == -1) {
perror("read");
exit(-1);
} else if(len > 0) {
printf("recv server : %s\n", recvBuf);
} else if(len == 0) {
// 表示服务器端断开连接
printf("server closed...");
break;
}
sleep(1);
}
// 关闭连接
close(fd);
return 0;
}
5. 端口复用
端口复用最常用的用途是:
- 防止服务器重启时之前绑定的端口还未释放
- 程序突然退出而系统没有释放端口
#include <sys/types.h>
#include <sys/socket.h>
设置套接字的属性(不仅仅能设置端口复用)
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
参数:
- sockfd : 要操作的文件描述符
- level : 级别 - SOL_SOCKET (端口复用的级别)
- optname : 选项的名称
- SO_REUSEADDR
- SO_REUSEPORT
- optval : 端口复用的值(整形)
- 1 : 可以复用
- 0 : 不可以复用
- optlen : optval参数的大小
端口复用,设置的时机是在服务器绑定端口之前。
setsockopt();
bind();
6. io多路复用(io多路转接)
I/O 多路复用使程序能同时监听多个文件描述符,提高程序的性能。
Linux 下实现 io多路复用 的系统调用主要有 select、poll、epoll。
几种常见的 io模型
-
阻塞等待: BIO模型 accept(),read()
好处:不占用 cpu时间片
坏处:同一时间只能处理一个操作,效率低
解决办法:使用 多进程或者多线程 实现并发服务器。
进程和线程会消耗资源
进程或线程的切换或者说调度消耗 cpu 资源
a客户端发起连接,被 accept(), 如果只有一个进程一个线程,则会被 read() 阻塞;当 b客户端此时也想建立连接,就没办法被 accept(),所以 accept() 由父进程或主线程负责,而 读写操作由子进程或子线程来负责,从而实现 并发服务器,同时和多个客户端建立连接并通信。
根本问题: blocking,阻塞导致的。 -
非阻塞,忙轮询,NIO模型
把 accept() read() 设置为非阻塞,然后每隔一定时间查看是否有 客户端的连接请求或者有数据可以读。
提高了程序的执行效率,但需要占用更多的 cpu和系统资源。
通过使用 io多路转接技术来解决。
-
多路转接模型
多个客户端一个服务器,即并发服务器。
对于 NIO 模型,多个客户端需要服务器来轮询处理,虽然提高了程序的执行效率,但消耗大量的系统资源 占用cpu时间片。
所以有了多路转接技术,将多个客户端委托给内核来处理,内核再通知服务器是否需要读写或者处理连接请求。工作单一化。
select 和 poll 类似,epoll 效率更高。
select
该系统调用 是委托内核对设置为需要检测的文件描述符进行检测,当有文件描述符对应的缓冲区发生改变,就返回,返回值为 number of ready descriptors.
当返回值大于0,比然有文件描述符丢应的缓存区发生变化,先判断是不是有新的客户端连接进来了,如果是,接收连接;然后是同通信,通信的话是遍历 文件描述符数组,直到需要检测的最大的文件描述符为止,遇到为1的文件描述符就是读缓冲区发生了变化,此时去读数据不会阻塞。
主旨思想:
- 首先要构造一个关于文件描述符的列表,将要监听的文件描述符添加到该列表中。
- 调用一个系统函数,监听该列表中的文件描述符,直到这些描述符中的一个或者多个进行I/O操作时,该函数才返回。
a. 这个函数是阻塞
b. 函数对文件描述符的检测的操作是由内核完成的 - 在返回时,它会告诉进程有多少(哪些)描述符要进行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);
select服务器代码如下
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/select.h>
int main() {
// 创建socket
int lfd = socket(PF_INET, SOCK_STREAM, 0);
struct sockaddr_in saddr;
saddr.sin_port = htons(9999);
saddr.sin_family = AF_INET;
saddr.sin_addr.s_addr = INADDR_ANY;
// 绑定
bind(lfd, (struct sockaddr *)&saddr, sizeof(saddr));
// 监听
listen(lfd, 8);
// 创建一个fd_set的集合,存放的是需要检测的文件描述符
fd_set rdset, tmp;
FD_ZERO(&rdset);
FD_SET(lfd, &rdset);
int maxfd = lfd;
while(1) {
tmp = rdset;
// 调用select系统函数,让内核帮检测哪些文件描述符有数据
int ret = select(maxfd + 1, &tmp, NULL, NULL, NULL);
if(ret == -1) {
perror("select");
exit(-1);
} else if(ret == 0) {
continue;
} else if(ret > 0) {
// 说明检测到了有文件描述符的对应的缓冲区的数据发生了改变
if(FD_ISSET(lfd, &tmp)) {
// 表示有新的客户端连接进来了
struct sockaddr_in cliaddr;
int len = sizeof(cliaddr);
int cfd = accept(lfd, (struct sockaddr *)&cliaddr, &len);
// 将新的文件描述符加入到集合中
FD_SET(cfd, &rdset);
// 更新最大的文件描述符
maxfd = maxfd > cfd ? maxfd : cfd;
}
for(int i = lfd + 1; i <= maxfd; i++) {
if(FD_ISSET(i, &tmp)) {
// 说明这个文件描述符对应的客户端发来了数据
char buf[1024] = {0};
int len = read(i, buf, sizeof(buf));
if(len == -1) {
perror("read");
exit(-1);
} else if(len == 0) {
printf("client closed...\n");
close(i);
FD_CLR(i, &rdset);
} else if(len > 0) {
printf("read buf = %s\n", buf);
write(i, buf, strlen(buf) + 1);
}
}
}
}
}
close(lfd);
return 0;
}
poll
poll 和 select 基本类似,不过改进了 select 的 文件描述符数组的数量大小限制以及不能重用的问题,因为 poll 用的不再是数组,而是结构体封装了文件描述符及处理事件,我们可以自定义其数量以及 重用文件描述符,因为我们每次只需要修改结构体中的参数即可,不会影响到需要继续检测的文件描述符有哪些。
#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个文件描述符发生变化
poll 和 select 不同的是:
select 用的是已经封装好数量的文件描述符数组,大小为1024,和内核中的文件描述符数组大小相同。也是通过操作用户区创建的这样一个文件描述符数组,设置自己想要检测的文件描述符,然后调用 select 时将数组拷贝到内核中,内核根据数组中设置的需要检测的文件描述符去检测,有对应的缓冲区发生变化,返回,根据返回的描述符变化个数去操作。
poll 同样是创建一个管理文件描述符的数组,不同的是该文件描述符数组可以复用。因为数组中存放的是一个个封装好的结构体,委托给内核后内核操作的是结构体中的一个变量,不影响下次仍然需要检测的文件描述符有哪些,从而不需要每次都复制一份 专门用于让内核操作的文件描述符数组。 解决了select的第三个 第四个缺点。
poll服务器代码如下
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <poll.h>
int main() {
// 创建socket
int lfd = socket(PF_INET, SOCK_STREAM, 0);
struct sockaddr_in saddr;
saddr.sin_port = htons(9999);
saddr.sin_family = AF_INET;
saddr.sin_addr.s_addr = INADDR_ANY;
// 绑定
bind(lfd, (struct sockaddr *)&saddr, sizeof(saddr));
// 监听
listen(lfd, 8);
// 初始化检测的文件描述符数组
struct pollfd fds[1024];
for(int i = 0; i < 1024; i++) {
fds[i].fd = -1;
fds[i].events = POLLIN;
}
fds[0].fd = lfd;
int nfds = 0;
while(1) {
// 调用poll系统函数,让内核帮检测哪些文件描述符有数据
int ret = poll(fds, nfds + 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;
int len = sizeof(cliaddr);
int cfd = accept(lfd, (struct sockaddr *)&cliaddr, &len);
// 将新的文件描述符加入到集合中
int i = 1;
for(; i < 1024; i++) {
if(fds[i].fd == -1) {
fds[i].fd = cfd;
fds[i].events = POLLIN;
break;
}
}
// 更新最大的文件描述符的索引
nfds = nfds > i ? nfds : i;
}
for(int i = 1; i <= nfds; i++) {
if(fds[i].revents & POLLIN) {
// 说明这个文件描述符对应的客户端发来了数据
char buf[1024] = {0};
int len = read(fds[i].fd, buf, sizeof(buf));
if(len == -1) {
perror("read");
exit(-1);
} else if(len == 0) {
printf("client closed...\n");
close(fds[i].fd);
fds[i].fd = -1;
} else if(len > 0) {
printf("read buf = %s\n", buf);
write(fds[i].fd, buf, strlen(buf) + 1);
}
}
}
}
}
close(lfd);
return 0;
}
epoll
epoll - I/O event notification facility. 输入输出事件通知功能
epoll部分 代码思路流程
- epoll_create() 创建一个 epoll实例 到内核中。
- epoll实例是一个 event_poll类型的结构体,结构体中我们重点理解的是 红黑树和双链表,红黑树用来保存需要检测的文件描述符,双链表用来保存检测到的有变化的文件描述符(传出)。
- 往 epoll实例中 添加要检测的文件描述符。
- 先创建一个结构体,给结构体中的参数赋值:如我们要检测的文件描述符、要检测的读还是写
- 调用 epoll_wait() 函数让 epoll实例工作
- 检测添加到红黑树中的需要检测的文件描述符以及需要检测的事件,检测到有变化的文件描述符就在链表中做记录,最终会输出到传入传出参数 epoll_event的数组中,并且返回发生变化的文件描述符的个数。
- 我们可以通过遍历 返回值的 结构体中的文件描述符,来进行和客户端的连接以及读写操作。
#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 */
};
常见的Epoll检测事件:
- EPOLLIN
- EPOLLOUT
- EPOLLERR
// 对epoll实例进行管理:添加文件描述符信息,删除信息,修改信息
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
- 参数:
- epfd : epoll实例对应的文件描述符
- 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
epoll服务器代码如下
#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(9999);
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);
epev.events = EPOLLIN;
epev.data.fd = cfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &epev);
} else {
if(epevs[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 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;
}
epoll 两种工作模式
LT 模式 (水平触发)- 只要有数据就通知
假设委托内核检测读事件 -> 检测fd的读缓冲区
读缓冲区有数据 - > epoll检测到了会给用户通知
- a.用户不读数据,数据一直在缓冲区,epoll 会一直通知
- b.用户只读了一部分数据,epoll会通知
- c.缓冲区的数据读完了,不通知
LT(level - triggered), 是缺省的工作方式,默认是这种工作模式。同时支持 block和no-block socket。在这种模式下,当一个文件描述符就绪,内核返回就绪文件描述符个数以及具体的文件描述符信息,我们就可以就其进行 读写操作了。如果一次读操作并没有 将缓冲区中的数据读完,下一轮检测内核还会继续返回该文件描述符的信息。
ET 模式(边沿触发)- 数据只通知一次,这次没读完下次不再通知
假设委托内核检测读事件 -> 检测fd的读缓冲区
读缓冲区有数据 - > epoll检测到了会给用户通知
a.用户不读数据,数据一直在缓冲区中,epoll下次检测的时候就不通知了
b.用户只读了一部分数据,epoll不通知
c.缓冲区的数据读完了,不通知
ET(edge - triggered),是高速工作方式,只支持 no-block socket. 在这种工作模式下,对于就绪的文件描述符,内核只会返回一次相关信息,如果一次读操作没有读完对应缓冲区,下次内核不会再返回这个文件描述符中还有数据没读完,所以在这种模式下,我们需要 while读写操作,只要内核返回一个文件描述符,我们就要把这个文件描述符对应缓冲区读写干净,并且要注意 read/write 操作必须为非阻塞。
也正因为这样,ET模式很大程度上减少了 epoll事件 被重复触发次数,从而提高了工作效率。epoll 工作在 ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
将文件描述符设置为非阻塞,然后在遍历到一个文件描述符需要读数据后,要 while循环 读取数据直到读完为止。这个时候要考虑:非阻塞文件描述符的缓冲区在读完数据后并不会返回 len=0,而是返回一个错误号 EAGAIN len=-1,所以在返回值为-1的语句体中 我们要加入错误号的判断,如果 errno=EAGAIN,就是正常读完数据了,继续下一轮循环即可,而不是错误退出。返回 0 意味着客户端断开连接了,所以才能返回读取数据为 0.
想用ET模式,需要在epoll_event设置EPOLLET
struct epoll_event {
uint32_t events;
epoll_data_t data;
};
常见的Epoll检测事件:
- EPOLLIN
- EPOLLOUT
- EPOLLERR
- EPOLLET
7. UDP通信
udp的接受与写入函数
#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 : 地址的内存大小
udp的客户端与服务器
udp服务器
#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;
}
udp客户端
#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;
}
8. 广播与组播(多播)
广播
向子网中多台计算机发送消息,并且子网中所有的计算机都可以接收到发送方发送的消息,每个广播消息都包含一个特殊的IP地址,这个IP中子网内主机标志部分的二进制全部为1。
- a.只能在局域网中使用。
- b.客户端需要绑定服务器广播使用的端口,才可以接收到广播消息。
设置广播属性的函数
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, "172.26.206.186", &cliaddr.sin_addr.s_addr);
// 4.通信
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;
}
组播(多播)
单播地址标识单个 IP 接口,广播地址标识某个子网的所有 IP 接口,多播地址标识一组 IP 接口。单播和广播是寻址方案的两个极端(要么单个要么全部),多播则意在两者之间提供一种折中方案。多播数据报只应该由对它感兴趣的接口接收,也就是说由运行相应多播会话应用系统的主机上的接口接收。另外,广播一般局限于局域网内使用,而多播则既可以用于局域网,也可以跨广域网使用。
- a.组播既可以用于局域网,也可以用于广域网
- b.客户端需要加入多播组,才能接收到多播的数据
组播地址
IP 多播通信必须依赖于 IP 多播地址,在 IPv4 中它的范围从 224.0.0.0 到 239.255.255.255 ,并被划分为局部链接多播地址、预留多播地址和管理权限多播地址三类:
设置组播
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);
}
// 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;
}
组播客户端
#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;
}
9. 本地套接字
本地套接字的作用:本地的进程间通信
- 有关系的进程间的通信
- 没有关系的进程间的通信
本地套接字实现流程和网络套接字类似,一般采用TCP的通信流程。
实现流程
本地套接字通信的流程 - 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("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;
}
本地套接字客户端
#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;
}
大体内容更新完成,不定时补充
版权声明:本文标题:Linux 网络编程相关知识 内容由热心网友自发贡献,该文观点仅代表作者本人, 转载请联系作者并注明出处:https://www.elefans.com/dongtai/1728313245a1153291.html, 本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容,一经查实,本站将立刻删除。
发表评论