区别 GCC 与 G++
__cplusplus
宏:g++编译 .c 文件时会定义,按照严谨的 cpp 语法去执行;gcc 编译 .cpp 文件时也会定义。gcc -lstdc++
即可解决 gcc 链接不了 c++库的问题-D
选项,可以指定一个编译时后的宏,可以方便调试。比如调试版本和 release 版本,就不用删除 log 了
# 编译,得到目标文件
gcc -c tes1.c tes2.c
# 打包成静态库: -r插入 -c -s起名
ar -rcs libxxx.a tes1.o tes2.o
# 使用:指定库名、库位置、头文件
gcc -o demo tes1.o tes2.o -l xxx -L ./lib -I ./include
优点:
缺点:
消耗资源,每个程序使用库,都要加载到内存。如果多个程序使用同一个库,那就太浪费了
更新发布麻烦,你把库发给别人,别人还要再编译一遍
# 编译得 与位置无关 的目标文件
gcc -fpic -c tes1.c tes2.c
# 编译成共享库
gcc -shared -o libxxx.so tes1.o tes2.o
# 使用共享库,指定:指定库名、库位置、头文件
# !!还要确保能找到库的绝对路径
可以使用ldd
命令来查看可执行文件所依赖的动态库,找不到的会提示"not found"。
程序执行时,加载动态库的过程是由动态加载器ld-linux.so
来完成的。他会依次寻找 elf 文件的 DT-RPATH 段(进程内存空间,改不了不用管)—>环境变量 LD_DIRECTORY_PATH
—>/etc/ld.so.cache
—>/lib /usr/lib
,找不到就报错。因此可以有以下方式指定路径:
#(1)更改环境变量
export LD_DIRECTORY_PATH=$LD_DIRECTORY_PATH:{your_lib_directory}
# 当然也可以修改 ~/.bashrc 或 /etc/profile
#(2)更改 /etc/ld.so.cache,但这是一个二进制文件,要更改它的config文件
sudo echo {your_lib_directory} > /etc/ld.so.conf
#(3)添加到 /lib /usr/lib 【不推荐】
.PHONY
,伪目标,那么就不会与外面的同名文件,如 clean 比较GDB 的功能,一般来说有:
|
core-file core
:查看core
文件【通过ulimit -a
查看,再设置 core 文件大小,编译时-g
即可生成 core 文件】
-g
只是把源文件的信息加入可执行程序中,并不是嵌入了源程序。因此调试时还需要能找到源程序
命令:
set args arg_1 arg_2
:给程序设置参数
show args
:显示参数
list 文件名 : 行号/函数名
:显示某文件,改函数或行号,附近的代码l
break 文件名 : 行号/函数名
:在该处设置断点b
info break
:查看断点i
delete/disable/enable 断点编号
:查看/删除/无效/生效断点d/del/dis/ena
break [pos] if [condition]
:条件断点
start/run
:开始,到第一行停下/开始,到断点停下start/r
continue
:执行到下一个断点c
next
:执行到下一行代码(不进入函数体)n
step
:向下单步调式(有函数就进入函数)s
finish
:跳出函数体(函数里面不能有断点)
util
:跳出循环(循环内不能有可用的断点,且要停在循环的第一行/最后一行)
print/ptype 变量名
:打印变量值/打印变量类型
display + 变量名
:自动打印指定变量的值
undisplay + 编号
:取消打印对应变量
info display
:查看显示信息i
set var 变量名 = 变量值
:更改变量的值
set disassemble-nextline on
:设置自动输出下一行代码的汇编
C 库的 IO 函数是可以跨平台的。跨平台,要么是像 java 那样不同平台开发不同的虚拟机,要么是调用不同平台的系统 API,从而统一接口。
标准 C 库带有缓冲区,因此效率是更高的
C 库函数的 man 手册等级是 3,可以man 3 fwrite
UNIX 系统 IO
C 库有缓冲区,可以提高读写效率,但注意,是效率,而不是速度。在网络通信情境中,我们希望能迅速收到对方发来的消息,那么此时应该使用系统 IO,即时读写。总不能我发来的信息,等半天了你还在缓冲区里呢
一个应用程序可以打开很多文件,因此需要一个数组,来存储其打开的文件描述符
所谓一切皆文件,0 1 2 是默认打开的,对应的就是当前终端,终端也是一个“文件”。
同一文件可以被打开多次,相互之间的文件描述符是不相同的,比如不同用途,一个只读,一个只写。文件释放掉时文件描述符才会释放。分配文件描述符时,就从尚未分配的数子中选择一个最小的。
系统 IO 在 man(2),标准 C 库在 man(3)。因此这里就不具体列举了,直接man 2
int open(const char *pathname, int flags, mode_t mode);
errno
flags
的几个可选项之间,采用的是 按位或 | ,其实就是给 flags(32 位)不同位上加个 1mode_t
参数指定文件的权限,最终的权限 = mode & ~umask
umask
作用就是抹去一些权限,保证你不会给错。mode_t umask(mode_t mode)
errno
:错误号,属于 Linux 系统函数库(而非属于函数),是全局变量,记录最近的错误号。
void perror(const char *s)
:打印 errno 对应的错误描述,其中参数 s 是用户描述,输出的样子是 用户描述(s) : errno 对应的错误
while((len = read(fd_read, buffer, sizeof(buffer))) > 0)
off_t lseek(int fd, off_t offset, int whence);
作用:把文件指针移动到whence + offset
的位置
返回值:成功返回文件指针相对于文件开始的偏移量,失败返回 -1
SEEK_SET
:设置文件指针的偏移量为 offsetSEEK_CUT
:设置文件指针的偏移量为 当前位置 + offsetSEEK_END
:设置文件指针的偏移量为 文件大小 + offsetlseek(fd, 0, SEEK_SET)
lseek(fd, 0, SEEK_CUR)
lseek(fd, 0, SEEK_END)
lseek(fd, enlarge_size, SEEK_END)
,要写入一定内容才会真正拓展stat [filename]
,用以查看文件的信息int stat(const char *pathname, struct stat *statbuf);
int lstat(const char *pathname, struct stat *statbuf);
(当文件是软链接时,获取链接本身的信息)struct stat {
dev_t st_dev; /* ID of device containing file */
ino_t st_ino; /* Inode number */
mode_t st_mode; /* File type and mode */
nlink_t st_nlink; /* Number of hard links */
uid_t st_uid; /* User ID of owner */
gid_t st_gid; /* Group ID of owner */
dev_t st_rdev; /* Device ID (if special file) */
off_t st_size; /* Total size, in bytes */
blksize_t st_blksize; /* Block size for filesystem I/O */
blkcnt_t st_blocks; /* Number of 512B blocks allocated */
};
判断各区段的值,只需要按位与,即st.st_mode & S_FLAGS
,得到的结果与各种宏进行比较即可。也正因如此,这些 FLAGS 的值看起来才这么“长”,因为它们都是 16 位的,直接与整个 st_mode 相与的,其余无关的位都置 0,起到一个过滤的作用。
access & chmod & truncate
int access("const char *pathname", int mode)
mode
:F_OK
判断文件是否存在,R_OK, W_OK, X_OK
判断文件的读/写/执行/权限int chmod(const char *pathname, mode_t mode);
/etc/passwd
和/etc/group
。也可以是用id + username
命令int truncate(const char *pathname, off_t length)
mkdir & rmdir & rename & chdir & getcwd
int mkdir(const char *pathname, mode_t mode);
int rmdir(const char *pathname);
int rename(const char *oldpath, const char *newpath);
int chdir(const char *path);
char *getcwd(char *buf, size_t size);
对应 shell 的命令:mkdir、rmdir、mv、cd、pwd。查手册时要注意使用 man 2 makdir
,否则默认打开的是第一章的 mkdir 命令
chdir
:修改进程的工作目录(默认是在当前工作路径,即,在哪个目录启动程序,进程默认路径就在哪,而不是程序所在的文件夹!)
getcwd
:返回值其实就是传递进去的 buffer 的地址
man (3)
DIR *opendir(const char *dirname);
struct dirent *readdir(DIR *dirp);
int closedir(DIR *dirp);
DIR *opendir(const char *dirname);
:
struct dirent *readdir(DIR *dirp)
;
int closedir(DIR *dirp);
int dup(int oldfd);
int dup2(int oldfd, int newfd);
用指定的 newfd 来指向 oldfd 指向的文件。如果 newfd 指向的有文件,则先执行 close 操作。这里 oldfd 必须是一个有效值,否则 newfd 没有意义。如果二者相同,则等于什么都没做。返回值就是 newfd
可以用dup2
来进行输入输出重定向,比如默认的输出是标准输出(fd1=STDOUT_FILENO),现在想让它输出到文件中(fd2=12),那么,就可以dup2(fd2, STDOUT_FILENO);
注意!重定向,是被更改的那一方作为 newfd,比如想重定向标准输出,则标准输出因该作为 newfd。因为 dup2 的作用是,关闭newfd
指向的文件,并让其指向oldfd
int fcntl(int fd, int cmd, ... /*args*/);
参数cmd
:命令,其实就是函数定义的一些宏,作用有五个:(列出了常用的两个)
复制文件描述符:F_DUPFD
,返回一个新的文件描述符
设置/获取文件状态:
F_GETFL
,获取文件描述符对应的状态,即open
函数中的flag
F_SETFL
,设置文件的状态 flag
O_APPEND、O_NONBLOCK(设置成非阻塞) . . .
阻塞/非阻塞:描述的函数调用的行为,函数对当前进程的影响。如,可籍此将管道文件设置为非阻塞
int fd = open("xxx");
// 首先要获取当前文件的FLAG
int flag = fcntl(fd, F_GETFL); // 失败返回-1,成功返回flag
// 然后,修改的时候,注意不是直接替换
flag |= O_APPEND; // 添加
flag &= ~O_APPEND; // 删除
int ret = fcntl(fd, F_SETFL, flag); // 失败返回-1,成功返回0
其实内核实现是不区分就绪态和运行态的。你占有 cpu 就是运行态,不占有就是就绪态
新建态:新建后进入就绪列表
终止态:可能是运行结束,也可能是被高权限者终结。终结后,进程不再继续执行,但仍然会留在操作系统中等待善后。等其他进程完成了对终止进程信息抽取后,操作系统才会将其从系统中删除。
{cmd} &
:在命令后面加上一个&
可以让其在后台运行(会定时将结果输出到前台来,但不影响输入输出)
pid_t fork(void)
:复制当前进程,但是运行在不同内存空间(仔细查看 fork(2)手册中的 DESCRIPTION)
创建成功,函数会返回两次:父进程中返回子进程 pid,子进程中返回 0
创建失败:父进程中返回-1,并设置 errno:
EAGAIN
:当前系统进程数达到上限
ENOMEM
:当前系统内存不足
如何区分父子进程?:利用 fork()函数的返回值。返回值为 0 说明是子进程,否则说明是父进程。由于父子进程是共享代码段的,因此通过添加一个 if-else 判断,来实现,两个进程执行相同代码,在某些地方又执行不同代码的效果。
理解子进程创建:
实际上,复制的实现,是通过“写时拷贝”(copy-on-write)来实现的,即,fork 后并不会真的直接复制一段内存,而是通过“只读共享”的方式,让父、子进程共用一段内存。只有在需要写入数据的时候,才真正进行拷贝,使得二者拥有独自的空间。
父子进程共享文件,共用相同的文件描述符、相同的文件偏移指针,引用计数增加
父子进程区别:
父子进程共同点
set follow-fork-mode [ parent | child ]
set detach-on-fork [ on | off ]
info inferiors
inferior id
detach inferiors id
函数族:一系列具有相同/相似功能的函数(C 语言中没有重载)
fork()
一个子进程,然后再子进程中调用 exec 函数族,来,一下子,把子进程变成一个实现别的功能的新进程,而不只是原进程的拷贝了其中只有最后一个
execve()
函数是 UNIX 的,别的都属于标准 C 库函数,是对 execve()的封装
函数名其实就指明了函数的参数的传递方式
int execl(const char *path, const char *args1, ...);
path
:要调用的文件路径。推荐使用绝对路径
args
:参数列表,直接传入多个,其中第一个参数约定为文件名,并且最后以NULL
结尾(哨兵)
例如:int ret_flag = execl("/usr/bin/ls", "ls", "-h", "-al", "/home/usr", NULL)
。即 ls -alh ~
注意到,shell 命令也是可以执行的,它也不过是个可执行程序嘛。这个-
,在命令既有参数又有文件名等时,不加是不行的。
注意有可能会产生孤儿进程
被 execl 族函数替换后,整个进程都变了,包括代码段等等。因此,原进程中 execl 函数后的代码将不会被执行!
int execlp(const char *file, const char *args1, ...)
file
:调用文件的文件名args
:仍然是参数列表void exit(int status)
:标准 C 库的退出,在 stdlib.h 中
void _exit(int status)
:系统调用,在 unisd.h 中
status
是退出时状态,父进程在回收子进程资源时能够获取到
区别在于,调用exit()
时,会先调用退出处理函数、刷新 IO 缓冲、关闭文件描述符等工作,然后再调用系统调用_exit()
退出进程。
举个例子,printf("hello\nworld");
然后调用exit(0)
,这是是能输出 hello\nworld 的。但是如果调用的是_exit()
,那就只会输出一个 hello。因为 C 库的 IO 是带有缓冲区的,\n 会刷新缓冲,因此 hello 一定能输出。但是调用_exit(),此时 world 尚在缓冲区里,但进程直接就退出了,没有输出
exit()会把 status 返回给父进程,就跟 main 的 return 一样,main 是被系统调用的,return 也是把状态返回给上一级。但只是对main而言,这两个一样的。
区分exit与return,只需要品味他们的名字即可。exit是进程退出,而return是返回,在main函数中调用别的函数,func,func和main都是在同一个进程中。这时return是返回,也即堆栈弹栈,是返回到调用者main,main还在;而exit是终结进程,main也没了。
孤儿进程:父进程运行结束,但是子进程还在运行,那么它就是孤儿进程
每当出现孤儿进程,操作系统就会把它的父进程设置为init
(pid = 1),init 会循环 wait()它已经退出的子进程,并最终回收其资源,做善后工作。因此孤儿进程并不会有什么危害
显然,对孤儿进程,其 ppid = 1
僵尸进程:每个进程结束后,内核都会释放掉该进程的所有资源、打开的文件、占用的内存等。但是仍为其保留一定的资源,主要是保留 PCB 信息(包括进程号、退出状态、运行时间等),需要父进程去释放。因此,子进程终止,且父进程还没来得及回收时,这时子进程残留资源(PCB)存放于内核中,即变成僵尸进程。
kill -9
杀死的wait()
或waitpid()
,来获取其退出状态的同时,杀死该进程wait 类函数,用于等待一个进程,直至其状态发生改变(终止、挂起、kill),然后获取它的信息。
对于进程终止(terminiate)的情况,调用 wait 可以让系统能够回收其资源,而不是让其变成僵尸进程。
如果状态已经改变,则函数立即返回,否则调用 wait 的进程会被挂起,直到其等待的进程收到了一个不可被忽视的信号。
区别在于,wait 会被阻塞,waitpid 可以设置不被阻塞,还可以指定等待哪个子进程结束。阻塞即,父进程 wait 不到时,就会停下手头的工作,去一直 wait 子进程,直至其结束。
一次 wait 函数只能清理一个子进程,清理多个应使用循环。
pid_t wait(int *wstatus);
wstatus
:一个 int 类型地址,是传出参数,用于获取返回值。返回值还要传入一些宏中,即可获得一些有用的状态:Wait-IF-EXIT-EnD,Wait-EXITSTATUS,Wait-IF-SIGNAL-EnD,Wait-TERM-SIGnal…
pid_t waitpid(pid_t pid, int *wstatus, int options);
pid
pid > 0
:即表示等待 pid 对应的子进程pid =0
:回收当前进程组的所有子进程,即当前调用者的进程组pid = -1
:回收所有的子进程,即使你跑到了别的组(最常用)pid < -1
:等待任意一个,gpid 等于参数绝对值的,组内的,任一子进程options
:设定阻塞/非阻塞
0
:阻塞WNOHANG
:非阻塞,立即返回> 0
:返回子进程 pid= 0
:(只有非阻塞情况下才会返回 0,即options = WNOHANG
)表示还有子进程活着= -1
:错误或者没有子进程了子进程“复制”了父进程的东西,但是那叫“读时共享、写时拷贝”,写的时候,是两个相互独立的区域。在逻辑上,二者是隔离的。只不过是实现上,有一段共享的时候。
进程是独立的,但不是孤立的
亲缘关系的进程能够共享管道的原因:
父子进程共享文件描述符表,管道具有文件的性质,因此可以操作同一个管道
环形队列。因为使用普通队列的话,写过的地方就不好再用了,采用环形队列可以节省内存空间。
int pipe(int pipefd[2]);
参数:一个 int 数组,是传出参数。pipefd[0]
对应管道的读端(输出),pipefd[1]
对应管道的写端(输入)
返回值:成功返回 0,失败返回-1
long fpathconf(int fd, int name);
:用于获取文件的一些信息,比如最大链接数、最长名字、最大管道尺寸等等,通过 name 传递一个宏值来指定功能。如_PC_LINNK_MAX, _PC_PAYH_MAX, _PC_PIPE_BUF
等等等void bzero(void *s, size_t n);
:将 s 指针指向位置开头的 n 字节用\0
填充(可能会受,头文件是string.h还是strings.h的影响)void *memset(void *s, int c, size_t n);
:将 s 指针指向位置开头的 n 字节用常字符(int)c
填充,并返回一个指向 s 区域的指针void *memcpy(void *dest, const void *src, size_t n);
:内存拷贝(利用内存映射来拷贝文件时可以用)都是在阻塞 IO 的情况下讨论:
返回0
,相当于读到了文件末尾被阻塞
。(如果设置了非阻塞,那么 read 将会返回-1)SIGPIPE
,通常会导致管道异常终止被阻塞
那么,管道也是一个文件,因此自然也可以设置管道文件的文件描述符的属性 ==> fcntl()
,将其设为非阻塞
命令行:mkfifo [fifoname]
int mkfifo(const char *pathname, mode_t mode);
参数
pathname
:带路径的文件名
mode
:与open()
的 mode 相同
成功返回 0,失败返回-1 并设置 errno
打开最好是,你是写的,就O_WRONLY
,你要是读的,就O_RDONLY
(其实同匿名管道)
read()阻塞
;read()返回0
SIGPIPE
信号(管道破裂),导致写端异常终止
。write()阻塞
参考读物:mmap 内存映射原理
内存映射 (Memory-mapped I/O) 是将磁盘文件的数据映射到内存,用户通过修改内存就能修改磁盘文件。
#include
// 映射到内存
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
// 解除映射
void munmap(void *addr, size_t length);
内存映射函数 m-map():
addr
:指定映射的内存起始地址。(最好传递NULL
,由内核选择地址进行映射)
length
:要映射的数据长度,要求大于 0 ;如果 length 未达到一页,则映射一页的大小【分页】。建议直接映射文件的大小(stat、lseek)
prot
:对申请内存映射区的操作权限,不能和打开的文件的权限冲突。最起码要有读的权限。通常是读,或者读写即可。PROT_READ,PROT_WRITE,PROT_EXEC,PROT_NONE
,相或
flags
:内存的权限
MAP_SHARED
:共享,即映射区的数据回自动与磁盘文件进行同步。如果要完成进程间通信则必须选择此 flags
MAP_PRIVATE
:不会与文件同步,如果发生数据改变,则会创建一个 copy,而不会改写源文件
MAP_ANONYMOUS
:实现匿名映射,即不需要文件实体了。那么
① 只能实现父子进程之间的映射
② 参数也要改变, fd = -1
,offset = 0
,flags = MAP_SHARED | MAP_ANONYMOUS
fd
:open 一个磁盘文件。注意,不能为空文件!且 open 指定的权限不能与 prot 权限冲突
offset
:必须指定 4K 整数倍。一般不用管,即置 0
返回值
:
MAP_FAILED
,即(void *) -1
接触内存映射函数 m-un-map()
addr
:要接触映射的内存始址length
:要和 mmap 的参数一样(指的是文件映射。匿名映射前面提过,设置参数MAP_ANONYMOUS
,只能实现父子进程之间的通信)
fork()之前
,先选择一个非空文件,并创建内存映射区。然后子进程复制父进程地址空间,即可实现父子进程共享内存映射区(注意,这与写时拷贝也不冲突)如果对 mmap()的返回值(ptr)做++操作(ptr++)
,munmap()是否能够成功?
如果 open()O_RDONLY
,mmap()时 prot 参数指定PROT_READ | PROT_WRITE
会怎样?
MAP_FAILED
。prot 的权限 <= open 的权限如果文件偏移量为 1000 会怎样?
MAP_FAILED
。必须为 4K(4096)的整数倍mmap()什么情况下会调用失败?
可以 open()的时候O_CREAT
一个新文件来创建映射区吗?
mmap()后关闭文件描述符,对 mmap()映射有没有影响
对ptr
越界操作令怎样?
内存映射不只可以进行进程通信,还可以实现比如文件复制等功能。牢记其原理:把文件映射到内存中,并且建立内存与文件之间的同步,把文件操作变成内存操作即可。
查看信号详细信息:man 7 signal
信号默认的五种处理动作:
Term
:终止进程Ign
:当前进程忽略掉这个信号Core
:终止进程,并生成一个 core 文件Stop
:暂停当前进程Cont
:继续执行当前被暂停的进程信号的几种状态:产生、未决、递达
SIGKILL
和SIGSTOP
不能被捕捉、阻塞或者忽略,只能执行默认动作
int kill(pid_t pid, int sig);
int raise(int sig);
void abord(void);
kill
:给指定进程发信号。注意,不一定是发送信号 9
pid
:取> 0
,表示发送给该进程,0
,发给当前进程组的所有进程,-1
,发给所有有资格发送信号去的进程(除了 init),<-1
给绝对值对应的进程组
sig
:取0
表示不发送信号
成功返回 0,失败返回-1
raise
:只给当前进程/线程发送信号。成功返回 0,失败返回-1
abord
:默认终止当前进程
unsigned int alarm(unsigned int seconds);
int setitimer(int which, const struct itimerval *new_val, struct itimerval *old_value);
alarm()
:设置定时器,当时间到了的时候,给当前进程发送SIGALRM
信号,默认终止当前进程。每个进程都有且只有一个计时器
seconds
:倒计时时间(秒),设置为 0 无效,即可通过alarm(0)
取消定时器setitimer()
:也是设置定时器,可以替代 alarm 函数,可以周期性定时,精度更高(微秒)
which
:指定定时器以什么时间计时
ITIMER_REAL
:真实时间,时间到达发送 SIGALRM
信号ITIMER_VIRTUAL
:用户时间,时间到发发送SIGVTALRM
ITIMER_PROF
:进程在用户态和内核态的时间,时间到达发送SIGPROF
new_val
:一个结构体,设置定时器的属性(这有一份示例代码)struct itimerval { /*定时器的结构*/
struct timeval it_interval; /* Interval for periodic timer,后面每次间隔时间 */
struct timeval it_value; /* Time until next expiration,延迟多久开始执行定时器 */
};
/* 就是说,只有第一次闹钟是等待it_value值后“响”,后面每隔it_interval值“响”一下 */
struct timeval { /*时间的结构*/
time_t tv_sec; /* seconds */
suseconds_t tv_usec; /* microseconds ,两个都要设置,否则可能是随机值*/
};
old_val
:记录上一次定时的时间参数(传出参数),不用可以设置为NULL
#include
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
signum
:要捕捉的信号,就是那一堆宏。SIGKILL
和SIGSTOP
不可被捕捉、忽略handler
:捕捉后要如何处理,是一个指向参数为 int(即信号编号),返回值为 void 的函数的函数指针
SIG_IGN
:忽略信号SIG_DFL
:采用信号默认的处理方式回调函数
:让内核调用你写的某一个函数来进行处理(回调函数介绍)SIG_ERR
建议使用 sigaction 而不是 signal,因此 sigaction 能满足更多的标准,更通用。
// 检查或者改变信号的处理方式(新版信号捕捉)
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
struct sigaction {
void (*sa_handler)(int); // 信号处理函数①
void (*sa_sigaction)(int, siginfo_t *, void *); //信号处理函数②,不常用,由flags指定
sigset_t sa_mask; // 在信号捕捉函数执行过程中,临时阻塞一些信号,不用记得清空
int sa_flags; // 指定信号的处理方式,一堆宏值,其中0表示用①,SA_SIGINFO表示用②
void (*sa_restorer)(void); // 废弃掉了,指定NULL即可
};
sigaction
:
signum
:要捕捉的信号,除了 sigkill 和 sigstopact
:捕捉到信号后的处理动作oldact
:上一次的处理动作,不用传入NULL
信号集在 PCB 中其实就是一个 01 数组(位图),信号尚未被处理,那就在未决信号集里面,值 1;阻塞了,那就在阻塞信号集里面值 1。
未决信号集中的信号不能马上被处理,还需要先跟阻塞信号集中对应的值比较,如果非阻塞,就发给进程去处理,发之后,就是送达状态,未决位就置 0 了
不能直接设置这些位,而是要借用对信号集的一些操作函数来实现改变。比如阻塞信号集默认都非阻塞,但可以通过函数来设置阻塞。
注意:下面这些函数操作的都是你自己创建的信号集,就是一个类似的数组,然后要通过一个系统调用sigpromask
才能映射到系统中的阻塞信号集中。一般操作的都是阻塞非阻塞属性(sigprocmask),未决信号集只有获取这个动作(sigpending)。
注意:在多线程环境中,应当使用 pthread_sigmask()
,其参数含义与作用完全相同
// sigset就是一个你自己创建的无符号长整型数组,函数什么清空添加删除,都是操作的这个数组
typedef __sigset_t sigset_t;
#define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct{
unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;
// 将信号集中的参数全部置为0/1,set是传出参数,即操作的信号集,成功返回0失败返回-1
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
// 将信号集中的某个信号置为1/0,表示阻塞/不阻塞这个信号,成功返回0失败返回-1
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
// 判断信号是否阻塞。返回1表述信号阻塞,0表示不阻塞,-1表示错误
int sigismember(const sigset_t *set, int signum);
/* 将自定义信号集中的数据设置到内核 阻塞 信号集中:设置阻塞、解除阻塞、替换 */
int sigprocmask(int how, const sigset_t *set, sigset_ *oldset);
/* 获取内核中的 未决 信号集*/
int sigpending(sigset_t *set);
sigprocmask
:将自定义信号集中的数据设置到内核中:设置阻塞、解除阻塞、替换
how
:设置函数工作的方式。不妨设内核中的阻塞集为 mask,用户自定义的为 set
SIG_BLOCK
:将用户设置阻塞信号添加到内核中,mask = mask | set
,set 中为 1 的内核中置为 1SIG_UNBLOCK
:根据用户设置的数据,解除内核中的阻塞,mask &= ~set
,set 中为 1 的内核中置为 0SIG_SETMASK
:根据用户的信号集覆盖内核中的阻塞集,mask == set
,set 是啥就是啥set
:用户自定义的信号集oldset
:传出内核中原来的数据集,即做一个备份,不用设置为NULL
EFAULT
出错,EINVAL
传入的 how 非法在以下三种情况下,会给父进程发送SIGCHILD
信号,而父进程默认会忽略此信号
SIGSTOP
信号而暂停SIGCONT
信号被唤醒时waitpid
函数,当前有终止的子进程就回收,如果没有(返回值为 0 或-1)就 break 结束 handler,返回主控函数,不影响父进程的正常工作。这样就可以实现,收到一次信号,就可以回收当前所有已经终止的子进程。共享内存是效率最高的进程间通信方式,注意与内存映射区分,内存映射还是基于文件的,只不过少了一次从内核缓冲拷贝到用户缓冲的时间,直接实现磁盘和用户缓冲区之间的同步
比较的时候要记住,凡是通过文件的 IO,不论读写,用户都是与内核的缓冲区进行交互的,内核缓冲区才负责读写文件,是用户—内核—文件的模式
key_t ftok(const char *pathname, int proj_id); // 获取一个创建共享内存的key,也可以自己指定
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);
**ftok()
:**根据文件名和给定的 int 值,生成一个共享内存的 key。如果文件和 int 值不变,那 key 值在哪生成都是一样的。所以可以由此实现不同进程用同一个 key 来创建/获取共享内存。
int shmget(key_t key, size_t size, int shmflg);
**shmget()
:**创建或获取共享内存标识,默认内存全部清 0
**key:**key_t 类型(整形),共享内存的标识符,一般用 16 进制表示,非 0。A 进程开辟了一段共享内存,获取这段内存的 ID。但是 B 怎么找到这块内存呢?所以 key,就是“暗号”,让 AB 可以定位到同一段物理共享内存,当然,他们返回的 shmid 也是一样的。
**size:**共享内存的大小,向上取整到分页大小,即按页对齐。如果是获取,那置为 0 就行了
**shmflg:**指定属性,如内存权限(八进制),和附加属性(创建|存在与否)
用法:IPC_CREAT | IPC_EXCL | 0664
。存在判断必须要与创建连用才行
**返回值:**成功返回共享内存引用的 ID(不是 key),失败返回 -1
void *shmat(int shmid, const void *shmaddr, int shmflg);
**shmat()
:**将一个物理的共享内存和当前进程关联(放进自己的虚拟地址空间中,在共享区)
**shmid:**共享内存的标识 ID,由shmget
返回值获取
**shmaddr:**共享内存在虚拟地址空间的起始地址,指定NULL
,让内核去指定。自己指定有可能出错
**shmflg:**对共享内存的操作,无非是读写等权限,注意,必须要有读权限
SHM_RDONLY
指定只读,0
默认是读写都有
**返回值:**成功返回共享内存虚拟地址空间的起始地址,失败返回((void*)-1)
写入的时候可以用memcpy()
,来把你想写的东西拷贝进去
读出的时候,也可以用memcpy()
,还可以把void *
类型指针转换成char *
读取
int shmdt(const void *shmaddr);
**shmdt()
:**解除共享内存和当前进程的关联,参数即虚拟共享内存的起始地址,成功返回 0,失败返回-1
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
**shmctl()
:**操作共享内存,常用来删除。内存只有主动删除才消失,与创建进程存在与否无关。
**shmid:**共享内存的 ID
**cmd:**要进行的操作
IPC_STAT
:获取共享内存当前状态,此时参数 buf
是传出参数IPC_SET
:设置共享内存状态,此时参数 buf
是传入参数IPC_RMID
:标记共享内存为待删除,此时参数 buf
没有用,设置NULL
**buf:**一个shmid_ds
结构体指针,存放需要设置或读出的参数,作用与cmd
的选择有关
struct shmid_ds {
struct ipc_perm shm_perm; /* Ownership and permissions */
size_t shm_segsz; /* Size of segment (bytes) */
time_t shm_atime; /* Last attach time */
time_t shm_dtime; /* Last detach time */
time_t shm_ctime; /* Creation time/time of last
modification via shmctl() */
pid_t shm_cpid; /* PID of creator */
pid_t shm_lpid; /* PID of last shmat(2)/shmdt(2) */
shmatt_t shm_nattch; /* No. of current attaches */
...
};
所谓删除是,先标记删除,将共享内存段的key
置 0,表示要删除这一段共享内存。只有等到共享内存的链接数为 0 的时候,才会真正删除这段内存。因此可以对一段内存进行多次删除,反正只有最后一个进程的删除动作才有效。
链接数等信息,就是保存在一个shmid_ds
结构体中
# (1) ipcs 显示进程间通信的一些信息
ipcs -a # 打印当前系统中 所有的 进程间通信方式 的信息
ipcs -m # 打印出使用 共享内存 进行进程间通信的信息
ipcs -q # 打印出使用 消息队列 进行进程间通信的信息
ipcs -s # 打印出便用 信 号 进行进程间通信的信息
# (2) ipcrm 进程间通信媒介的 删除手段
ipcrm -M {shmkey} # 移除用 shkey 创建的 共享内存段
ipcrm -m {shmid} # 移除用 shmid 标识的 共享内存段
ipcrm -Q {msgkey} # 移除用 msqkey 创建的 消息队列
ipcrm -q {msqid} # 移除用 msqid 标识的 消息队列
ipcrm -S {semkey} # 移除用 semkey 创建的 信号
ipcrm -s {semid} # 移除用 serid 标识的 信号
终端是一个设备(命令tty
可以查看),用户通过终端登录后,获得一个 shell 进程(命令echo $$
显示当前 shell 的 pid),这个进程的**控制终端(Controlling Terminal)**即是此终端。
进程中,控制终端的信息是写在 PCB 中的,因此,从此 shell 启动的所有进程,其控制终端都是本终端(因为 fork()会复制 PCB),标准输入输出错误默认指向控制终端,因此也就都是指向本终端。
可以在控制终端输入一些命令来控制进程,但这只对前台进程有效。因为后台进程(在启动命令后面加一个$
或者用Ctrl + Z
即可得到后台进程)是没有控制终端的。
相关进程放在同一个组里,方便管理,比如修改进程优先级,直接把组的改了就行了
创建会话的两步,第一步会话首进程创建一个进程组,组号等于自己的进程号;第二步,会话首进程创建一个会话,会话号等于自己的进程号。因为会话是进程组的集合嘛,得先创建一个组
控制终端的第一个程序,会创建一个会话。一个终端只有一个会话;任意时刻,一个终端都有且只有一个前台进程组。下面是一个示例
find / 2 > /dev/null | wc -l &
sort < longlist | uniq -c
fork()
,之后父进程退出,子进程继续执行。
setsid()
开启一个新的会话
umask
,以确保当守护进程创建文件和目录时拥有所需的权限/
/dev/null
,并使用 dup2()重定向到这个设备
插播一段别的知识:
未初始化数据段,即
.bss
里面存放的是,未初始化,或初始化为 0 的,全局变量或静态变量。而.data
数据段,里面存放的是初始化为 非 0 的全局变量或静态变量。在程序运行起来之后,这俩是合在一块的,统称为数据区。之所以弄这个区别,是因为,bss 段里面的变量只有个符号,其实并没有实际分配内存,只有在运行起来后,连接器才会把后面跟着的这一块 bss 区,全部清零。而 data 段里面的,你初始化了 1000 个 int 的数组,它就真真切切的占用了 1000*4B 的空间,可执行程序就会大一些。
线程之间共享同一个虚拟地址空间,只有一些地方有区别,比如不同线程间,有各自的.text
段和栈空间
信号掩码即阻塞信号集,每个线程都有自己的阻塞信号集。每个线程都有自己的 error
// 创建线程:传出线程号,指定线程属性(NULL)、线程业务(函数指针)和线程执行需要的参数(void*)
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
// 获取线程自己的 tid,类似getpid()
pthread_t pthread_self();
// 用于比较两个线程的线程号是否相等。因为不同平台线程号的实现不同,有的用的是结构体,就没法 == 了
int pthread_equal(pthread_t t1, pthread_t t2);
进程是一个比较大的概念,是用来分配资源的单位,线程是用来调度的单位。main 函数中执行的是主线程,其余的是子线程。
参数
thread
:传出参数,创建成功后,会把子线程的 id 存放在其指向的那一块缓存中,再返回attr
:设置线程的属性,一般使用`NULL``start_routine
:即线程要执行的代码。是一个指向 参数为 void* 返回值也为 void* 的函数 的函数指针arg
:给线程要执行的函数 传递其需要的参数返回值
:成功返回 0, 失败返回 一个错误号(不是 -1 了,也不同于 errno,不能 perror 了)。现在的错误处理函数定义在 string.h 头文件中:char *strerror(int errnum);
pthread
不是标准库,因此有时编译不成功时,记得加上-pthread
选项(或者加-lpthread
指定也一样)
void pthread_exit(void *retval);
retval
:传出自己的返回值,可以在pthread_join()
中获取到
return retval;
(对指针熟练了可以跳过下面这两段话)
线程返回值,不能返回局部变量。每个线程有自己的栈,线程退出了,这块栈也就销毁了。那么你返回的值,再去读取,可能就不是原来的值了。因此要返回全局或者静态变量,而这个,也正是下面 pthread_join 第二个参数是个二级指针的原因。因为返回值是个“全局”(指针类型)变量,你获取到之后,如果传递的是一级指针,是不就等于 C 语言函数传参里面的,传值方法?也就是说,你不能去修改它,因此要想能回这个值进行修改,就要返回一个指向它的指针,指向指针的指针,那就是二级指针。
换言之,join 的工作过程其实就是,如果 retval 非空,则把目标线程的返回值(一个 void* 类型的数据),拷贝到 retval 指向的地址。(这里不要钻牛角尖,指向指针的指针,还是个指针,也还是指向一块存放数据的内存。只不过对这块内存的解读是,又是一个指针。类型不就是标记怎么解读比特数据的么)看出区别了没,其实跟之前传出参数一样,之前是用指针传出(而不是传值),现在也是用指针传出。区别就在,这次要传的参数,是一个指针。
// 连接终止的线程,连接的目的,就是回收其资源,并获取其返回值
int pthread_join(pthread_t thread, void **retval);
子进程有一部分不能自己释放的资源,需要父进程回收。子线程也有,也需要回收,不同的是,任何线程都可以去回收另一个结束的线程。(一般也都是主线程调用)
回收不了会阻塞;一次只能回收一个,回收多个需要用循环
参数
thread
:需要回收的子线程的 tidretval
:回收子线程的返回值(不管是 return 返回的,还是调用 pthread_exit() 返回的,一样)返回值
:成功返回 0, 失败返回错误号// 分离指定的线程
int pthread_detach(pthread_t thread);
// 取消指定线程(让线程终止)
int pthread_cancel(pthread_t thread);
线程可以被取消与否,取决于被取消线程的一些属性,(即创建线程时的第二个参数pthread_attr_t
)
被取消的线程,不是立马终止的,而是要运行到一些被定义好的取消点(cancellation point,可以man 7 pthreads
查看)时,才会被终止。大致上可以理解为,要运行到某些系统调用处,发生内核与用户态切换时,才会被终止。
// 设置/获取线程的分离属性
int pthread_attr_setdetachstate(pthread_attr_t *attr, int detachstate);
int pthread_attr_getdetachstate(const pthread_attr_t *attr, int *detachstate);
// PTHREAD_CREATE_DETACHED PTHREAD_CREATE_JOINABLE
首先要创建线程属性变量,然后对其进行初始化pthread_attr_init
,然后设置其属性。设置了分离状态PTHREAD_CREATE_DETACHED
就说明,不需要别的进程来join我,我结束了自己会释放。然后pthread_create
创建线程,把 attr 传进去就好了。
设置了 detach 属性,就不能再去 pthread_detach
或者 pthread_join
了,跟执行一次pthread_detach()
一样
restrict
:C语言修饰符,被修饰的指针,不能被别的指针操作
锁的使用步骤:创建锁(全局) ⇒ \Rarr ⇒ 初始化锁(使用前) ⇒ \Rarr ⇒ 加锁(阻塞)或尝试加锁(非阻塞) ⇒ \Rarr ⇒ 操作 ⇒ \Rarr ⇒ 解锁
初始化一个已经被初始化的锁,和销毁正在被用着的锁,都是未定义行为。但是一个锁被销毁后,是可以再被重新初始化的。合理的去destory锁,反正在主线程退出之后destory,和退出之前,但是别的线程还在用着呢的时候destory,都是不合适的。个人认为,本身他也就是一个栈上的变量而已,全局的联合体变量。你destroy它,它也不会释放空间,只是变得可以再被init了而已。没有需要就不destroy了
wait
是一直等待,直到被通知,timedwait
是等待指定时间,如果没收到通知就不等了pthread_cond_wait(&cond, &mutex)
等待cond被阻塞的时候,系统会自动解锁,等到信号,准备就绪的时候,mutex这个锁又会被加上signal
是至少通知一个正在等待的,broadcast
是通知阻塞队列里的全部同样的,信号量也是用来阻塞线程的,可以实现同步互斥前驱关系等,并不能保证线程安全,也即,想要保证多线程数据安全,仍然需要配合锁来实现(当然,单个信号量,值取01,那跟锁一样了)
信号量是有值的,条件变量是没有值的,是看满不满足条件
sem_init
:初始化信号量
0
表示线程间同步,非0
表示进程间同步sem_wait
:V 对信号量加锁,使信号量值减一,如果 value 大于零则直接返回;如果 value 小于 0 则阻塞
sem_post
:P 对信号量解锁,使信号量值加一,如果让信号量的值大于0,则唤醒一个被 wait 阻塞的线程
每个网卡的mac地址是全球唯一的,由制造商烧录在网卡中
IP,Internet Protocal Address
,分配一个逻辑地址,屏蔽物理地址的差异
子网掩码
端口(port),0-65535(2^16-1)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4yClxQ6o-1683112061210)(C:/Users/wddjwk/AppData/Roaming/Typora/typora-user-images/image-20230329192844263.png)]
协议,即网络协议的简称,是通信计算机双方必须遵从的一组约定,比如怎样建立连接,怎样互相识别等。只有遵守这个约定,之间才能互相通信交流。
协议的三要素是,语法、语义、时序。为了使数据在网上从源到达目的,网络通信的参与方必须遵守相同的规则,这套规则称为协议(protocal),他最终体现为,在网络上传输的数据包格式。
协议往往分成几个层次,分层的定义是为了使某一层协议的改变,不影响其他层次。
上层协议是如何便用下层协议提供的服务的呢?其实这是通过封装 (encapsulation) 实现的。应用程序数据在发送到物理网络上之前,将沿着协议栈从上往下依次传递。每层协议都将在上层数据的基础上加上自己的头部信息(有时还包括尾部信息)以实现该层的功能,这个过程就称为封装。
当帧到达目的主机时,将沿着协议栈自底向上依次传递。各层协议依次处理帧中本层负责的头部数据,以获取所需的信息,并是终将处理后的帧交给目标应用程序。这个过程称为分用 (demultiplexing)。分用是依靠头部信息中的类型字段实现的。
地址解析协议:通过 IP 找 MAC 地址。把 ip 封装成 ARP 包,加上以太网帧头尾,进行广播,与目标地址相同的进行应答。
命令:arp -a
即大端小端。大端指:整数的高位在低地址,输出是“顺”的;小端指:低位在高地址,输出是“反”的。
举个例子,0x112233,最高字节是11,最低字节是33,则大端时输出其字节(访存是从低地址开始,往高地址读取),最高位在低地址,所以是0x11,0x22,0x33;即与人读的顺序(从左往右)一致,人先看先读的就是高位。小端则相反,先拿到的是低位数据,因为低位放在低地址。
可以通过强制转为unsigned char *
输出每一 byte,也可以创建一个 union
,里面定义一个数据类型 x,一个char数组,这样输出char数组就可以得知 x 的字节序
TCP/IP中将网络字节序规定为:大端序。发送端如果是小端机则要先转换为大端序,接收端按大端序解释,后面随便(转换成你的主机字节序)。
网络中端口、IP都没有负数,都采用 unsigned
格式。【注意是整数类型之间的转换,与inet_pton()
等函数区别】
#include
/* h->host; n->net; s->short(unsigned short: 2B); l->long(unsigned int: 4B) */
uint16_t htons(uint16_t hostshort); // short 一般用来转换 端口 地址(2B)
uint16_t ntohs(uint16_t netshort);
uint32_t htonl(uint32_t hostlong); // long 一般用来转换 IP 地址(4B)
uint32_t ntohl(uint32_t netlong);
这里有一个定义 ip 的操作:先定义到字节数组里面,然后对其进行指针类型转换即可。
char buff[4] = {192, 168, 5, 11};
unsigned long x = *((unsigned long *)buff);
unsigned long y = htonl(x);
unsigned char *p = (unsigned char *)&x;
unsigned char *q = (unsigned char *)&y;
printf("x: %d.%d.%d.%d\n", *p, *(p + 1), *(p + 2), *(p + 3));
printf("y: %d.%d.%d.%d\n", *q, *(q + 1), *(q + 2), *(q + 3));
作用:格式转换
/*a->address; n->network,即网络字节序的整数; p->point,即点分十进制字符串*/
#include
// 【不推荐使用】
in_addr_t inet_addr(const char *cp); // 字符串IP 转换为 大端 整数 IP
int inet_aton(const char *cp, struct in_addr *inp);
char *inet_ntoa(struct in_addr in);
// 下面俩同时适用IPv4和IPv6【推荐使用】
int inet_pton(int af, const char *src, void *dst);
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
af
:地址族,AF_INET
,AF_INET6
二选一src
:点分十进制IP字符串 or 网络字节序的二进制整数dst
:【传出参数】网络字节序的二进制整数 or 点分十进制IP字符串size
:指定 dst 的大小(dst字符数组的大小)返回值
:转换后的地址,与 dst
中内容一样(整数to字符串的情况)为什么要三次握手而不是两次?
为什么主动断开的一方,要等待 2MSL(Maximum Segment Lifetime:两倍的最长报文段寿命)才能关闭
保证 A 发的最后一个确认报文段(即第四次挥手)能准确到达 B,如果 A 不等待 2MSL 就关闭,且此时第四次回收的报文丢了,A 无法再重传,那么 B 就无法正常关闭。
同采用三次握手的原因类似,等待 2MSL 可以保证本次连接中产生的所有报文段都从网络中消失。以免产生“已失效的连接请求报文段”等问题。
第一次挥手后,被动方给主动方发送 ACK,但尚未发送 FIN,此时主动方进入 FIN_WAIT_2
状态,这时的主动方,可以接受被动方发来的消息,但是无法发出消息,即半关闭(半连接)状态。可以通过 API 来控制这一状态。
int shutdown(int sockfd, int how);
how
:允许 shutdown 操作选择以下几种方式
SHUT_RD(0)
:关闭 sockfd 上的读功能,该套接字不再接收数据,其接收缓冲区中的数据将被直接丢弃SHUT_WR(1)
:关闭 sockfd 上的写功能,进程无法再对此 socket 进行写操作SHUT_RWDR(2)
:关闭 sockfd 读写功能,相当于调用 shutdown 两次注意区别:使用close()
中止连接,即发送 FIN
,但这只是减少 socket 描述符的引用计数,不会直接关闭。只有等引用计数减为 0 时才真正关闭。而 shutdown()
是立刻关闭连接,或者关闭一个方向,不考虑引用计数。即多进程通信时,一个进程调用 close 并不会影响别的进程通信,但有人调用的 shutdown 则都不能通信了。
socket(套接字(插座)),就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象,一个套接字就是网络上进程通信的一端。上联应用进程,下联网络协议栈。socket 是一个伪文件。
(1)通用 socket 地址
表示 socket 地址的是一个结构体,有两个:sockaddr
和 sockaddr_storage
,定义如下:
#include
typedef unsigned short int sa_family_t; // 2B
struct sockaddr {
sa_family_t sa_family; // 表示地址族类型,与协议族对应,通常可以混用
char sa_data[14]; // 存放socket地址值。不同协议族的地址具有不同的长度和含义
};
struct sockaddr_storege {
sa_family_t sa_family;
unsigned long int __ss_align;
char __ss_padding[ 128 - sizeof(__ss_align) ];
};
上图可见,sockaddr
中的 14B 的data是不够容纳所有协议的地址的,因此又新定义了一个 sockaddr_storage
,以便容纳更长的地址,并且它是内存对齐的。
(2)专用 socket 地址
很多网络编程的函数诞生是早于IPv4协议的,当时用的都是 sockaddr
结构体,为了向前兼容,现在sockaddr 退化成了,类似 void*
的作用,就是说,不管你是什么地址,统统转为 sockaddr 类型传给函数。至于这个地址到底怎么解释,是sockaddr_in
还是sockaddr_in6
,由协议族字段确定,然后函数内部再强制类型转换为相应的地址。
所有的专用socket地址(包括socket_storage)都要强制转换为通用socket地址类型(即sockaddr),因为所有socket编程接口的地址参数类型都是sockaddr
/* sockaddr_in: TCP/IP协议族中IPv4的sock地址,sockaddr_in6 则是IPv6的地址 */
#include
struct sockaddr_in {
sa_family_t sin_family; // 协议族(AF_INET <=> PF_INET)
in_port_t sin_port; // 端口号(16位无符号,2B)
struct in_addr sin_addr; // IP地址(32位五符号,4B)
/*填充的部分*/
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;
};
typedef uint16_t in_port_t;
typedef uint32_t in_addr_t;
#define __SOCKADDR_COMMAN_SIZE ( sizeof(unsigned short int) )
sockaddr_in 地址指定方法为:
struct sockaddr_in addr; // 开始赋值:
addr.sin_family = AF_INET; // IPv4
addr.sin_port = hton(9999); // 字节序转换
// 使用 IP 转换函数给需要的整形 sin_addr 赋值
inet_pton(AF_INET,"192.168.5.11", (void*)&addr.sin_addr.s_addr);
// 也可以采用下面偷懒的写法。客户端不行,客户端一定要指定你想连接哪个服务器
addr.sin_addr = INADDR_ANY; // 其实就是0.0.0.0,表示客户端进来任意地址都可以绑定
⭐️Socket 缓冲区
#include
#include
#include // 包含了这个,上面的两个可以省略
/* 1. 服务端建立 */
int socket (int domain, int type, int protocal);
int bind (int sockfd, const struct sockaddr *addr, socklen_t addrlen);
int listen (int sockfd, int backlog); /* /proc/sys/net/core/somaxconn */
int accept (int sockfd, struct sockaddr *addr, socklen_t *addrlen);//长度是指针!!
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
/* 2.1 读写函数 */
ssize_t write (int sockfd, const void *buf, size_t len);
ssize_t read (int sockfd, void *buf, size_t len);
/* 2.2 socket 专有的读写函数(就多了个 flags 选项) */
ssize_t recv (int sockfd, void *buf, size_t len, int flags);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *src_addr_len);
ssize_t send (int sockfd, const void *msg, size_t len, int flags);
ssize_t sendto (int sockfd, const void *msg, size_t len, int flags,
const struct sockaddr *to_addr, socklen_t to_addr_len);
/* 3. 关闭socket连接,参见 4.7.6 半关闭 */
int close (int sockfd);
int shutdown (int sockfd, int how);
/* 4. 设置 socket 属性,参见 4.9.3 端口复用 */
int getsockopt(int sockfd, int level, int optname, void *optval, socklen_t*optlen);
int setsockopt(int sockfd, int level, int optname,const void *optval, socklen_t optlen);
cman 2 socket
)
domain
:协议族,AF_INET , AF_INET6, AF_UNIX/AF_LOCAL...
type
:通信过程中使用的协议类型(流 报…),SOCK_STREAM,SOCK_DGRAM,SOCK_SEQPACKET...
protocal
:具体的协议,一般传 0
。流式协议默认TCP
,报式默认UDP
返回值
:成功返回 socket 文件描述符,失败返回 -1sockfd
:上一步创建的 socketaddr
:本地(服务器的) IP 和 端口 结构体addrlen
:addr 的大小返回值
:成功 0, 失败 -1sockfd
:要监听的 socket,即刚通过 socket()
获得的那个backlog
:两个套接字队列的总最大长度(似乎与man手册不一样,查)(在somaxconn中定义)sockfd
:用于监听的那个 socket(即listen()
用的那个),因为要从里面的缓冲区读取客户端的数据addr
:传出参数,记录连接成功后,链进来的 客户端的 IP 和 端口addrlen
:是指针!!!,不能直接 sizeof 了,先定义一个变量。返回值
:成功返回用于通信的 socket 描述符,失败返回 -1sockfd
:客户端自己创建的,用于通信的文件描述符addr
:要连接的服务器的 IP 和 端口addrlen
:addr 的 size返回值
:成功 0,失败 -1flags
取值如下:recv(2)
MSG_WAITALL
:阻塞,直到所有的request都满足才返回,除非中间出现错误或异常(信号、断连)MSG_DONTWAIT
:不阻塞,不能读就直接返回MSG_CMSG_CLOEXEC
:一执行,就把接受的 fd 关闭(只接收一次呗)0
:啥也不干,跟 read 一样flags
取值如下:send(2)
MSG_DONTWAIT
:不阻塞,不给发就返回MSG_NOSIGNAL
:不产生 SIGPIPE
信号MSG_EOF
:shutdown 发送方向的连接,并发送一个数据尾标识(仅对IPv4的TCP有效)0
:啥也不干,跟 write 一样基本模型:
SIGCHILD
信号,以便在子进程完成通信后回收它。回收的处理函数应当是一个非阻塞的循环waitpid,有子进程在工作就返回父进程,使其得以继续监听。errno
为 EINTR
。要解决这个问题,便要在 accept 的返回值为 -1 时进行一次判断,如果errno == EINTR
,则 continue 即可。 多线程的思想同多进程类似,也是主线程负责监听,接收到一个就创建一个子线程去处理。这里有一些关于传参的问题:
创建线程,我有多个参数:客户端信息、线程号、通信文件描述符…,如何把这些参数传递给线程的回调函数呢?
⇒ \Rightarrow ⇒ 很简单,只需要创建一个结构体,然后传递结构体指针即可。
创建的结构体,如果放在主线程的 while 循环中,即一个局部变量,那肯定是不行滴,传参不能传局部变量的指针,因为下一个循环它就没有了。
⇒ \Rightarrow ⇒ 法一:给结构体在堆上分配内存(用malloc或new),缺点是需要自己管理内存
⇒ \Rightarrow ⇒ 法二:把结构体创建成一个全局变量。但是既然是多线程环境,如果要同时提供多个服务,都要传参,那肯定是需要一个结构体数组了。
既然要预先创建数组,那你得指定大小呀。指定了大小,就会有不够用的问题。也即,首先要确定,怎么从数组中找出当前可以用的结构体呢?如果找不到(用完了)又该怎么办呢?
⇒ \Rightarrow ⇒ 首先是怎么找:当然是循环遍历了。如果是遍历结构体数组,那么开始时要对结构体数组进行初始化,将其中的客户端文件描述符置为-1,线程使用完后同样置为-1。这样,遍历找 client_fd 不是 -1 的结构体就好了
⇒ \Rightarrow ⇒ 也可以设置一个可用数组,甚至可以采用位图的方法,专门描述哪些结构体数组可用。
⇒ \Rightarrow ⇒ 比如结构体数组大小设置为128,那意思就是,我这个服务器,最多只能并发128个线程。那多的请求呢?比如遍历到最后没找着,那就让其先睡一会儿,然后再去从头或者别的地儿遍历一下。找不着接着睡
另外,对子线程采用pthread_join
来回收显然也是不可取的,因为它会阻塞主线程。设置分离属性即可。
比如正在运行的 server 和 client,突然 server 被关掉了,此时使用 netstat -anp
命令会发现,client处于 close_wait
状态,服务器不存在了,监听端口状态也没了,但是这条连接的,服务器端口还在,并处于 fin_wait_2
状态,此时再结束客户端,服务器端口则处于 time_wait
状态(过一会就结束了)
就是说,服务器关闭了,但是 TCP 协议的任务还没完成,端口还被占用着。如果在这个 2MSL 的时间内,我想重启服务器,那么会发现,端口被占用了启动不了。为了避免这种情况,我们可以在绑定端口之前,指定端口复用属性。
netstat -anp
# -a 所有 socket
# -n 显示正在使用 socket 的程序的名称
# -p 直接使用 IP 地址,而不通过域名服务器
int getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen);
int setsockopt(int sockfd, int level, int optname,const void *optval, socklen_t optlen);
以上两个是设置/获取 socket 属性的函数,不只可以用来设置端口复用(UNP7.2(P150))
端口复用要在服务器绑定之前设置(socket 之后,bind 之前 setsockopt)
SOL_SOCKET
(把选项解释为 通用套接字代码)SO_REUSEADDR
或 SO_REUSEPORT
0
表示不可以复用,1
表示可以复用【int 型】从内核角度看IO模型
解决方法是,创建多个线程,让别的线程去进行交互,这样主线程一直监听,就可以实现并发。
但根本的问题没解决:即这是一个阻塞的模型,阻塞,就导致程序不能走下去,才会有后面的一系列问题
要遍历去询问准备好了没(O(n))。这种模型已经可以做到:用很少的线程去处理多个连接了。
=> IO 多路转接/复用技术,
委托内核去帮我们做轮询 => select ,但是select只告诉你到了几个,不会说具体哪一个
内核直接帮你把谁到了都检测出来 => epoll
对网络数据包接收流程可以分为两个阶段:
DMA
的方式将数据包拷贝到内存中,然后经过硬中断,软中断,接着通过内核线程ksoftirqd
经过内核协议栈的处理,最终将数据发送到内核Socket
的接收缓冲区中。内核Socket
的接收缓冲区中时,此时数据存在于内核空间
中,需要将数据拷贝
到用户空间
中,才能够被应用程序读取。阻塞 IO ,就是会在第一阶段,数据准备阶段 阻塞,非阻塞 IO 就是在第一阶段不阻塞,如果读不到,就直接返回。而无论阻塞非阻塞,在第二阶段都是阻塞的。在第二阶段阻塞的叫同步 IO,在第二阶段不阻塞的叫异步 IO
同步模式
在数据准备好后,是由用户线程
的内核态
来执行第二阶段
。所以应用程序会在第二阶段发生阻塞
,直到数据从内核空间
拷贝到用户空间
,系统调用才会返回。
异步模式
下是由内核
来执行第二阶段的数据拷贝操作,当内核
执行完第二阶段,会通知用户线程IO操作已经完成,并将数据回调给用户线程。所以在异步模式
下 数据准备阶段
和数据拷贝阶段
均是由内核
来完成,不会对应用程序造成任何阻塞。
基于以上特征,我们可以看到异步模式
需要内核的支持,比较依赖操作系统底层的支持。
I/O 多路复用使得程序能够同时监听多个文件描述,能够提高程序的性能。Linux 下实现 I/O 多路复用的系统调用主要有:select、poll、epoll
主旨思想:
#include
/* 让内核监听列表中的文件描述符 */
/* sizeof(fd_set) = 128B = 1024 位,用位来表示对应的文件描述符 */
int select (int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,
struct timeval *timeout);
void FD_CLR (int fd, fd_set *set); // 将 fd 对应的标志位置 0
void FD_SET (int fd, fd_set *set); // 将 fd 对应的标志为置 1
int FD_ISSET(int fd, fd_set *set); // 判断 fd 对应的标志位是否为 1,并返回该标志位值
void FD_ZERO (fd_set *set); // 将 set 中标志位全置为 0
nfds
:委托内核进行检测的文件描述符 最大数量 + 1(加 1 才能遍历到max,因为从0开始嘛)readfds
:【传入传出参数】设置位,让内核检查读缓冲,看对应文件描述符上是否有谁发来数据可读,有的话不变,没有的话,就会把对应的位置为0,再从内核拷贝回用户态writefds
:让内核检查写缓冲,看是否有空余位置可写exceptfds
:检查异常timeout
:设置等待多久;如果为 0 表示不阻塞,为 NULL 表示永久阻塞返回值
:大于 0 表示有 n 个文件描述符发生了变化,0表示超时了且没有检测到,-1表示失败【问题来了】,select 怎么多线程呢?新客户端进来之后,如果要创建新线程,那么新线程不还是要BIO
poll 和 select 没有本质上的区别,只不过是,select 用的是 fdset 这么个位数组来传给内核,而 poll 用的是一个 pollfd 结构体数组来和内核传递消息。这就没有了fd最多1024的限制(但仍受限于系统的fd上限,这是显然的)。结构体数组中同时包括了文件描述符 和读写事件的区分,因此不需要分成三个 fdset 来传参
至于工作的过程,仍然需要拷贝,仍然需要轮询,仍不是能解决 C10k 问题(处理10k个并发连接)的好调度
#include
struct pollfd {
int fd; /* 委托内核进行检测的文件描述符 */
short events; /* 委托内核检测什么事 */
short revents; /* 内核反馈实际上发生了什么事 */
};
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
fds
:同文件描述符表,只不过换成了结构体数组,没有 1024 的限制了nfds
:fds 数组的有效下标 + 1timeout
:0 不阻塞,-1 阻塞,>1 表示要阻塞的 毫秒 数返回值
:-1失败,n:fds中有n个文件描述符有变化宏取值
:使用多个用 |
,判断是否相等用 &
【注意,不能用 == 判断与某个宏值相等】fds[0].revents & PILLIN
,说明有新客户端,则进行 accept;然后将新的客户端文件描述符加入到fds数组中,要用for循环遍历找空位,以免浪费,更新 nfdsrevents & POLLIN
的fd,进行通信poll 改进了 select 的3 4缺点,即使用了一个结构数组,数组项可以重用了,也没有上限了。但仍有缺点:
无论是 select 还是 poll,它们都存在着下列问题:
epoll 对这三个问题都进行了解决。它会在内核中创建一个epoll对象,这里面包括一个红黑树rbr
:用来存放用户需要监听的文件描述符,和一个双向链表rdlist
:用来保存内核找到的就绪描述符,并返回给用户。当然还有别的数据,比如一个阻塞队列wq
:存放的是阻塞在epoll上的用户进程,在IO就绪的时候epoll可以通过这个队列找到这些阻塞的进程并唤醒它们,从而执行IO调用读写Socket的数据。
下面先看 epoll 的 API
#include
/**epoll_create
* @brief : 创建一个新的 epoll 实例(即内核中创建了一块数据结构)
* - 需要检测的文件描述符信息(红黑树)
* - 和就绪列表,存放内核检测到发生变化的文件描述符(双链表)
* @parameter:
* A size: 没有意义,随便指定。(历史遗留问题,因为之前是用hash实现的)
* @return: -1失败,>0 指向内核实例的文件描述符*/
int epoll_create(int size);
struct epoll_event {
unint32_t events; // 发生了什么事件
epoll_data_t data; // 用户数据(Union),一般只用到 fd
};
typedef union epoll_data {
void *ptr;
int fd; // 用这个传递 fd(注意这是个 Union)
unint32_t u32;
unint64_t u64;
}epoll_data_t;
/**epoll_ctl
* @brief: 对 epoll 实例进行管理,比如添加个文件描述符
* @parameter:
* 1. epfd : create 返回的文件描述符,操作内核实例
* 2. op : 要进行什么操作
* # EPOLL_CTL_ADD: 往红黑树中添加文件描述符
* # EPOLL_CTL_MOD: 修改
* # EPOLL_CTL_DEL: 删除
* 3. fd : 要操作/添加的文件描述符
* 4. event: 检测文件描述符什么事件(结构体,包括事件和文件描述符(跟fd一样))
* # EPOLLIN : 写入
* # EPOLLOUT
* # EPOLLERR
*/
int epoll_ctl (int epfd, int op, int fd, struct epoll_event *event);
/**epoll_wait
* @brief : 真正的调用函数
* @parameter:
1. epfd :
2. event : 结构体数组,【传出参数】保存发生变化的文件描述符,以及发生了肾莫事
同样是一个指针,ctl函数中传入的是单个,wait函数传出的是数组
3. maxevent : 前一个结构体数组的大小
4. timeout : 0不阻塞,-1阻塞直到检测到数据发生变化,>0阻塞时常,单位毫秒
@return : 成功返回发生变化的文件描述符的个数 >0,失败返回-1
*/
int epoll_wait (int epfd, struct epoll_event *events, int maxevents, int timeout);
可见epoll解决了如下问题:
❓ epoll的回调函数
ep_poll_callback
正是epoll
同步IO事件通知机制的核心所在,也是区别于select,poll
采用内核轮询方式的根本性能差异所在。(⛓内核角度看IO模型,源码实现,以后再看)
红黑树
管理海量的连接,所以在调用epoll_wait
获取IO就绪
的socket时,不需要传入监听的socket文件描述符。从而避免了海量的文件描述符集合在用户空间
和内核空间
中来回复制。IO就绪
的 socket。避免了在用户空间遍历的开销ep_poll_callback
通知用户程序IO就绪
的socket。避免了在内核中轮询的开销。epoll_create
,直接在内核创建一个数据结构(红黑树),返回文件描述符。通过文件描述符可以去操作这个内核区的实例epoll_ctl
把监听文件描述符添加EPOLL_CTL_ADD
进内核的红黑树中epoll_wait
来让内核干活,返回一个发生变化的文件描述符列表epoll_event
结构体数组(1)LT 模式(水平触发)
缺省即为此模式,可以同时支持阻塞和非阻塞 socket。这种模式下,内核会告诉你一个文件描述符是否就绪了,然后你可以对此就绪的 fd 进行 IO操作。如果你不操作,或者没操作完(没读完),内核下一次还是会继续通知你这个 fd 可操作。
即:只要 fd 缓冲区中有数据,epoll 就会一直通知(每一轮),直到读完
(2)ET模式(边缘触发)
一种高速的工作方式,只支持 non-block socket,描述符变为就绪状态的时候,内核会通知你一次,后面就不会再为这个 fd 发送更多的信息了,直到某些操作导致其变为非就绪状态
即:只在第一次 fd 就绪的时候通知一下,后面不管是读完没读完,还是一直没读导致 fd 又变为非就绪,都不会再通知了。直到你进行了某些操作使其变为非就绪,才会再新的轮次中发送有关该 fd 的可用通知
两种模式比较
ET 很大程度上减少了 epoll 时间被重复出发的次数,因此效率比 LT 高。必须使用非阻塞接口,以免由于一个 fd 阻塞而把多个 fd 饿死
本质区别就在于:
rdlist
中,然后下次调用的时候还会返回这个 sockfd,还能接着读rdlist
清空了,因此,不管你读没读,完没完,你下次都读不到这个 sockfd 了。除非,这个socket上有新的IO数据到达,根据epoll的工作过程,该 socket 会被再次放入 rdllist
中。5 种 IO 模型是从内核空间的视角,来剖析网络数据的收发模型。而站在用户空间的视角,即可得到两种IO线程模型。即 Reactor 模型和 Proactor 模型
这些用户空间的IO线程模型
都是在讨论当多线程一起配合工作时谁负责接收连接,谁负责响应IO 读写、谁负责计算、谁负责发送和接收,仅仅是用户IO线程的不同分工模式罢了。
Reactor
是利用NIO
(非阻塞)对IO线程
进行不同的分工
select, poll, epoll, kqueue
,进行IO事件的注册和监听。dispatch
到各个具体的处理Handler
中进行相应的IO事件处理。Reactor 模型依赖 IO 多路复用技术(epoll),来实现监听 IO 事件,不断的分发dispatch
,就像一个反应堆
一样,看起来像不断的产生IO事件,因此我们称这种模式为Reactor
模型。
具体分为三类:
单Reactor单线程 | 单Reactor多线程 | 主从Reactor |
epoll_wait
,获取IO就绪的Socket,然后对这些就绪的Socket执行读写,以及后边的业务处理也依然是这个线程。相当于一个线程(小老板)要完成 accept事件(迎客) 、接受IO请求(顾客点菜)、业务处理(做菜)、IO响应(上菜)、断开连接(送客)
epoll_wait
来获取所有就绪 socketread 事件
注册到从 Reactor
中,由从Reactor来监听 socket 上的读写事件注意:这里向
从Reactor
注册的只是read事件
,并没有注册write事件
,因为read事件
是由epoll内核
触发的,而write事件
则是由用户业务线程
触发的(什么时候发送数据是由具体业务线程决定的
),所以write事件
理应是由用户业务线程
去注册。
用户线程注册
write事件
的时机是只有当用户发送的数据无法一次性
全部写入buffer
时,才会去注册write事件
,等待buffer重新可写
时,继续写入剩下的发送数据、如果用户线程可以一股脑的将发送数据全部写入buffer
,那么也就无需注册write事件
到从Reactor
中。
Proactor
是基于AIO
对IO线程
进行分工的一种模型。前边我们介绍了异步IO模型
,它是操作系统内核支持的一种全异步编程模型,在数据准备阶段
和数据拷贝阶段
全程无阻塞。
ProactorIO 线程模型将 IO事件的监听
,IO操作的执行
,IO结果的dispatch
统统交给内核
来做。
completion handler
为用户程序定义的异步IO操作回调函数,在异步IO操作完成时会被内核回调并通知IO结果。Completion Event Queue
异步IO操作完成后,会产生对应的IO完成事件
,将IO完成事件
放入该队列中。Asynchronous Operation Processor
负责异步IO
的执行。执行完成后产生IO完成事件
放入Completion Event Queue
队列中。Proactor
是一个事件循环派发器,负责从Completion Event Queue
中获取IO完成事件
,并回调与IO完成事件
关联的completion handler
。Initiator
初始化异步操作(asynchronous operation
)并通过Asynchronous Operation Processor
将completion handler
和proactor
注册到内核。aio_read
,并告诉内核
用户空间中的读缓冲区地址,以便内核
完成IO操作
将结果放入用户空间
的读缓冲区,用户线程直接可以读取结果(无任何阻塞
)。Initiator
初始化aio_read
异步读取操作(asynchronous operation
),并将completion handler
注册到内核。在
Proactor
中我们关心的IO完成事件
:内核已经帮我们读好数据并放入我们指定的读缓冲区,用户线程可以直接读取。在Reactor
中我们关心的是IO就绪事件
:数据已经到来,但是需要用户线程自己去读取。
IO操作
完成时会产生一个completion event
事件,将这个IO完成事件
放入completion event queue
中。Proactor
从completion event queue
中取出completion event
,并回调与IO完成事件
关联的completion handler
。completion handler
中完成业务逻辑处理。Reactor
是基于NIO
实现的一种IO线程模型,Proactor
是基于AIO
实现的IO线程模型。Reactor
关心的是IO 就绪事件
,Proactor
关心的是IO 完成事件
。Proactor
中,用户程序需要向内核传递用户空间的读缓冲区地址
。Reactor
则不需要。这也就导致了在Proactor
中每个并发操作都要求有独立的缓存区,在内存上有一定的开销。Proactor
的实现逻辑复杂,编码成本较 Reactor
要高很多。Proactor
在处理高耗时 IO
时的性能要高于 Reactor
,但对于低耗时 IO
的执行效率提升并不明显
。池是一组资源的集合,在服务器启动之初就被完全创建好并初始化,是静态资源。线程池是由服务器预先创建的一组子线程,线程池中的线程数量,应该和 CPU 核心数量差不多。线程池中的所有子线程运行着相同的代码,当有新任务到来时,主线程通过某种方式选择一个子线程来位置服务。空间换时间。
相比动态创建子线程,选择一个已经存在的子线程代价小得多。至于如何选择: