进程通信-套接字编程(13)

视频教程

IPC(Inter-Process Conmunication)进程间通信

一、管道

管道必须凑齐读写双方才可以实现

管道可以看作为队列。先入先出,队首出,队尾入

在C语言中,使用管道(pipes)进行进程间通信是一种经典方法。管道主要用于进程间传递数据

管道由内核提供,单工,具有自同步机制

自同步机制:管道的自同步机制指的是管道在进程间通信时内置的同步特性,这些特性确保了数据的一致性和顺序性。管道通常是阻塞的,这意味着在特定情况下,读操作和写操作会被阻塞,直到某些条件得到满足。这种行为形成了管道的基本自同步特性。

管道分为:匿名管道与命名管道



1.匿名管道

匿名管道是一种简单的进程间通信(IPC)机制,主要用于有父子关系的进程间的通信。在Unix和类Unix系统(如Linux)中,匿名管道非常常见。它们是单向的通信方式,通常用于一个进程向另一个进程(具有父子关系的进程)发送数据

基本特性

  • 单向通信:数据只能在一个方向上流动,要么是从父进程到子进程,要么是从子进程到父进程。

  • 临时通信通道:管道在使用完毕后会被销毁。

  • 数据流:管道中的数据是按照先进先出(FIFO)的顺序流动的。

  • 内存中的存在:匿名管道不对应于文件系统中的任何文件,它们仅存在于内存中。

创建与使用

  • 在C语言中,可以通过pipe()系统调用来创建一个匿名管道。这个调用会创建管道并返回两个文件描述符:一个用于读(pipefd[0]),另一个用于写(pipefd[1])。

(1)pipe函数

在Unix和类Unix系统中,pipe() 函数用于创建一个匿名管道,用于在进程间进行通信。这个函数是进程间通信(IPC)的基础工具之一,特别是在需要在有父子关系的进程之间传递数据时。

函数原型

在 C 语言中,pipe() 函数的原型定义在 <unistd.h> 头文件中,如下所示:

1
2
3
#include <unistd.h>

int pipe(int pipefd[2]);

参数

  • pipefd:一个包含两个整型元素的数组。函数成功执行后,pipefd[0] 会被设置为管道的读端,而 pipefd[1] 会被设置为管道的写端

返回值

  • 成功时,返回 0
  • 失败时,返回 -1 并设置 errno 以指示错误原因

使用方式

当你调用 pipe() 函数时,它会创建一个管道并提供两个文件描述符(0端作为读取端口,1端作为写入端口):一个用于读取管道(pipefd[0]),另一个用于写入管道(pipefd[1])。这些文件描述符可以在后续的读写操作中使用

实例程序

main.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
/*
* 使用匿名管道实现父子进程间的通信
* 父进程在管道中进行读取(0端--队首进行出队)
* 子进程在管道中进行写入(1端--队尾进行入队)
* */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

#define BUFSIZE 1024

int main() {
pid_t pid;
char buf[BUFSIZE];
int len;

// 创建管道
// pipe成功后,它会创建一个管道并提供两个文件描述符
int pd[2];
if(pipe(pd)<0)
{
// 创建管道失败
perror("pipe()");
exit(1);
}

// 创建子进程
pid = fork();
if(pid < 0)
{
// 创建子进程失败
perror("fork()");
exit(1);
}

// fork函数在子进程中返回0
if(pid == 0)
{
// 子进程的操作
// 0端进行读取--出队
close(pd[1]); // 读管道则不需要写端,将写端关闭
// 将管道中读取的内容放入buf中,长度为BUFSIZE
len = read(pd[0],buf,BUFSIZE);
// 将读取的内容,即buf中的内容写入终端,标准输出文件描述符为1
write(1,buf,len);
// 关闭0端
close(pd[0]);
// 退出子进程
exit(0);
}else{
// 父进程的操作
close(pd[0]); // 写管道则不需要读端,将读端关闭
// 1端进行写入--入队
write(pd[1],"HELLO!\n",7);
// 关闭1端口
close(pd[1]);
// 阻塞回收子进程资源
wait(NULL);
}
// 退出父进程
exit(0);
}

运行结果

image-20231228171026110



2.命名管道

命名管道(也称为 FIFO,即“先进先出”)是一种在不相关的进程之间进行通信的机制。与匿名管道不同,命名管道具有在文件系统中实际存在的路径名,这允许不具有共同祖先的进程之间进行通信

基本特性

  • 有名字:与匿名管道只存在于内存中不同,命名管道在文件系统中有一个实际的名字。
  • 持久性:即使没有进程使用它们,命名管道也会继续存在。
  • 双向通信:理论上支持双向通信,但在实际使用中通常作为单向通信使用,以避免复杂的同步问题。
  • 先进先出原则:数据以先进先出的方式传输。

使用场景

  • 两个不相关的进程通信:例如,一个进程负责生成数据,另一个进程负责处理数据。
  • 跨程序通信:不同程序之间可以通过命名管道进行简单的数据交换。

注意事项

  • 命名管道的读取和写入操作通常是阻塞的,除非特别设置为非阻塞方式
  • 需要确保合适地打开和关闭管道,避免资源泄露
  • 与匿名管道类似,命名管道的数据也是没有内部结构的字节流,因此需要额外的协议或约定来解释数据。

创建与使用

在Unix和类Unix系统中,命名管道通常可以通过两种方式创建:

mkfifo 是 Unix 和类 Unix 系统(如 Linux)中用于创建命名管道(FIFO)的函数命名管道允许不相关的进程进行通信,不同于匿名管道,它在文件系统中以特定名称存在,而不仅仅是进程间的临时通信通道

  • 命令行:使用 mkfifo 命令
1
mkfifo [path_to_fifo]
  • C语言:使用 mkfifo() 函数
1
2
3
4
#include <sys/types.h>
#include <sys/stat.h>

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

(1)命令行创建管道
1
$ mkfifo namedfifo

查看创建的管道信息

1
$ ls -l namedfifo

如下,文件权限被标明为p,表明这是一个管道文件

image-20231228210547643

可以使用date显示时间日期信息

image-20231228210910142

在一个终端,将data信息重定向,输入至管道(内容写入管道),如下

1
$ date > namedfifo

另外打开一个终端,读取管道内的内容

image-20231228211017881




二、消息队列

1.ftok函数

在 Unix 和类 Unix 系统中,ftok 是一个用于 IPC(进程间通信)的标准库函数,它根据一个现有的文件名和一个字节大小的项目标识符,生成一个 System V IPC 键(通常称为 key_t 类型)。这个键通常用于创建或访问消息队列、共享内存或信号量

功能

ftok 函数的主要目的是提供一种方法,以便不同的进程可以使用相同的键来访问同一段共享内存、消息队列或信号量。通过这种方式,可以保证在不同的进程中创建或访问相同的IPC结构

ftok函数是可以获得一个用于进程间通信IPCkey_t

函数原型

1
2
3
#include <sys/ipc.h>

key_t ftok(const char *pathname, int proj_id);
  • pathname:是指向一个已存在文件的路径的指针。这个文件用作生成键的基础。
  • proj_id:是一个字符,用于和 pathname 一起生成一个独特的键。它通常是一个非零字符。

返回值

  • 成功时,ftok 返回一个 key_t 类型的值,该值可以被用于后续的IPC操作。
  • 失败时,返回 (key_t) -1,并设置 errno 以指示错误原因。

注意事项

  • ftok 并不保证生成的键是唯一的,尽管在大多数情况下它是足够有效的。
  • 使用 ftok 时,确保参考文件存在且对所有需要通信的进程可访问。
  • 由于 ftok 返回的键可能在不同系统上有所不同,因此它不适合用于跨系统的 IPC


2.消息队列的操作

消息队列是一个双工的操作

(1)msgget函数

在 Unix 和类 Unix 系统中,msgget 函数是用于创建或访问消息队列的系统调用。消息队列是一种进程间通信(IPC)机制,允许进程以消息的形式交换数据。这些消息按照先进先出(FIFO)的顺序在队列中排列

功能

msgget 函数用于创建新的消息队列或获取对现有消息队列的访问权限。它是 System V IPC 机制的一部分

函数原型

1
2
3
4
5
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>

int msgget(key_t key, int msgflg);
  • key:是一个IPC键,通常由 ftok 函数生成。特殊值 IPC_PRIVATE 可用于创建一个新的、私有的消息队列。

  • msgflg是一个整数,指定操作模式和权限。它可以是权限位的组合(类似于文件权限,例如 0644),以及可能包括以下标志之一:

    IPC_CREAT:如果指定的键不存在,则创建一个新的消息队列。

    IPC_EXCL:与 IPC_CREAT 结合使用时,如果消息队列已存在,则返回错误

返回值

  • 成功时,msgget 返回一个非负整数,表示消息队列的标识符(ID)。
  • 失败时,返回 -1,并设置 errno 以指示错误原因

注意事项

  • 确保在使用 msgget 之前调用 ftok 生成有效的 IPC
  • 使用 msgget 创建的消息队列在系统级别是持久的,除非显式删除(使用 msgctl)或系统重启
  • 使用消息队列进行进程间通信时,需要定义一种消息结构,并遵循先进先出的原则进行操作

(2)msgsnd与msgrcv函数
msgsnd

msgsnd 用于向消息队列发送消息

函数原型

1
2
3
4
5
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>

int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
  • msqid:消息队列标识符,由 msgget 函数返回。

  • msgp:指向消息结构体的指针。消息结构体的第一个字段通常是一个长整型 (long),用于表示消息类型。

  • msgsz:消息数据部分的长度,不包括消息类型字段。

  • msgflg:控制消息发送行为的标志。常用的标志包括:

    IPC_NOWAIT:如果消息不能立即发送(例如,队列已满),则不等待,直接返回。

    如果不设置 IPC_NOWAIT,则在无法立即发送消息时,调用将阻塞直到能够发送为止。

返回值

  • 成功时返回 0。
  • 失败时返回 -1,并设置 errno
msgrcv

msgrcv 用于从消息队列接收消息

函数原型

1
2
3
4
5
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>

ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
  • msqid:消息队列标识符,由 msgget 函数返回。

  • msgp:指向消息结构体的指针。消息结构体的第一个字段通常是一个长整型(long),用于表示消息类型。

  • msgsz:消息数据部分的大小,不包括消息类型字段。

  • msgtyp:指定接收消息的类型。如果 msgtyp 为 0,则接收队列中的第一个消息;如果 msgtyp 大于 0,则接收队列中第一个类型等于 msgtyp 的消息;如果 msgtyp 小于 0,则接收队列中类型值最小且不大于 msgtyp 绝对值的第一个消息。

  • msgflg:控制消息接收行为的标志。常用的标志包括:

    IPC_NOWAIT:如果没有符合条件的消息,则不等待,直接返回。

    MSG_NOERROR:如果队列中的消息长度超过 msgsz,则截断消息而不是报错。

    如果不设置 IPC_NOWAIT,则在没有符合条件的消息时,调用将阻塞直到有消息为止。

返回值

  • 成功时返回接收到的消息的大小(字节)。
  • 失败时返回 -1,并设置 errno 以指示错误的原因。


3.msgctl

msgctl 函数是 Unix 和类 Unix 系统中的一个系统调用,用于控制消息队列。它是 System V 消息队列接口的一部分,提供了对消息队列执行各种控制操作的功能,如改变队列属性、获取队列信息和删除队列

函数原型

1
2
3
4
5
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>

int msgctl(int msqid, int cmd, struct msqid_ds *buf);
  • msqid:消息队列标识符,由 msgget 函数返回。

  • cmd:指定要执行的控制操作。常用的操作包括:

    IPC_STAT:获取消息队列的当前状态和属性,并将其存储在 buf 指向的 msqid_ds 结构体中。

    IPC_SET:根据 buf 指向的 msqid_ds 结构体中的值设置消息队列的属性。

    IPC_RMID:立即删除消息队列,释放与之关联的所有资源。

  • buf:指向 msqid_ds 结构体的指针,用于存储或设置消息队列的状态信息。对于 IPC_STATIPC_SET 命令,这个参数是必需的。对于 IPC_RMID 命令,它通常被忽略。

返回值

  • 成功时返回 0。
  • 失败时返回 -1,并设置 errno 以指示错误的原因。


4.实例代码

创建一个发送端,向消息队列中发送信息,再创建一个接收端,取出消息队列中的信息

准备工作:在项目目录下创建文件,命名为Communication_Service的文件,将该文件的路径传入ftok函数中用于生成进程通信的IPC

(1)协议

创建通信协议,定义收发数据的结构体

proto.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/*
* 规定通信协议
* */
#ifndef MSG_PROTO_H
#define MSG_PROTO_H

#include <sys/ipc.h>
#include <sys/types.h>
#include <sys/msg.h>

#define KEYPATH "./Communication_Service" // ftok 函数中使用的pathname ftok 使用路径
#define KEYPROJ 'g' // ftok 函数中使用的proj_id 项目标识符 g ASCII码生成一个进程通信的键
#define NAMESIZE 1024

// 定义通信的数据结构
struct msg_st{
long mtype; // 当前消息类型(必需)
// 下面的才是消息数据部分 上面的消息类型不属于消息数据部分,因此在计算消息数据大小时,需要将消息类型的大小进行减去
// 即 sizeof(struct msg_st) - sizeof(long)
char name[NAMESIZE];
int math;
int chinese;
};

#endif //MSG_PROTO_H

(2)接收端

接收消息队列中的数据信息,在该实例程序中,应该先运行接收端程序,等待消息队列中有数据时,取出

rcver.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
/*
* 接收方
* 被动端:接收消息队列中信息,先运行
* */
#include<stdlib.h>
#include <stdio.h>

#include "proto.h"


int main()
{
key_t key;
int msgid;
struct msg_st rbuf;


// ftok函数 生成通信的键
// 返回一个key_t 类型的健值用于后续的进程通信IPC
key = ftok(KEYPATH,KEYPROJ);
if(key < 0)
{
// 失败
perror("ftok()");
exit(1);
}

// msgget 创建消息队列或者获取现有消息队列的访问权
/*
* 参数1:传入一个用于IPC的key_t值
* 参数2:消息队列指定的操作模式(如创建IPC_CREAT)与权限(0600)
* 返回值:成功时,返回一个非负整数,即消息队列的标识符(ID)
* */
msgid = msgget(key,IPC_CREAT|0600);
if(msgid < 0)
{
perror("msgget()");
exit(1);
}

// 接收消息队列的信息
/*
* 参数1:消息队列标识符,由 msgget 函数返回
* 参数2:指向消息结构体的指针(间接收的消息存储值协议中指定的消息结构体中)。消息结构体的第一个字段通常是一个长整型(long),用于表示消息类型
* 参数3:消息数据部分的最大大小,不包括消息类型字段 因此需要(消息类型结构体大小 - 消息类型大小long)
* 参数4:指定接收消息的类型,无特殊要求直接写0
* 参数5:控制消息接收行为的标志,无特殊要求直接写0
* 返回值:成功时返回接收到的消息的大小(字节)
* */
while(1)
{
if(msgrcv(msgid,&rbuf,(sizeof(rbuf)-sizeof(long)),0,0) < 0)
{
perror("msgrcv()");
exit(1);
}
printf("NAME = %s\n",rbuf.name);
printf("MATH = %d\n",rbuf.math);
printf("CHINESE = %d\n",rbuf.chinese);
}

// 操作消息队列--删除队列
/*
* 参数1:消息队列标识符,由 msgget 返回
* 参数2:指定要执行的命令。常见的命令包括:IPC_RMID立即删除消息队列
* 参数3:用于存储或设置消息队列的信息,于 IPC_RMID 命令,通常被忽略设置为NULL
* */
if(msgctl(msgid,IPC_RMID,NULL) < 0)
{
perror("msgctl()");
exit(1);
}


exit(0);
}

(3)发送端

snder.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/*
* 发送方
* 主动端:发送消息的一方
* */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include "proto.h"

int main()
{
key_t key;
struct msg_st sbuf;
int msgid;

// ftok函数 生成通信的键
key = ftok(KEYPATH,KEYPROJ);
if(key < 0)
{
perror("ftok()");
exit(1);
}

// msgget 创建消息队列或者获取现有消息队列的访问权
/*
* 参数2:在该实例程序中,被动方已经创建了消息队列,因此可以不做额外操作,写0
* */
msgid = msgget(key,0);
if(msgid < 0)
{
perror("msgget()");
exit(1);
}

// 填充发送的消息结构体信息
sbuf.mtype = 1;
strcpy(sbuf.name,"ZXZ");
sbuf.math = 100;
sbuf.chinese = 100;

// 向消息队列发送信息
if(msgsnd(msgid,&sbuf,(sizeof(sbuf)-sizeof(long )),0) < 0)
{
perror("msgsnd()");
exit(1);
}

puts("Send OK!\n");

exit(0);
}

(4)运行结果

在项目目录下,打开两个终端,分别对收发端程序源码进行编译。如下:

1
$ make snder
1
$ make rcver

image-20231229184205007

运行接收端,可执行文件,等到发送端,发送信息

1
$ ./rcver

运行发送端发送信息

1
$ ./snder

发送端终端结果:

image-20231229184419146

接收端终端结果:

image-20231229184437903

结束程序之后,在项目目录下的终端运行命令

1
$ ipcs

可以发现我们创建的消息队列实例没有被删除

image-20231229185557108

可以使用如下命令对消息队列,或者共享内存或者信号量数组进行删除

1
$ ipcrm

使用方法如下:

ipcrm 是 Unix 和类 Unix 系统中用于删除 IPC(进程间通信)资源的命令行工具。这个工具允许用户删除消息队列、共享内存段和信号量集,这些是 System V IPC 的主要构成部分

ipcrm 的基本语法如下:

1
$ ipcrm [options]

常见的选项包括:

  • -q msqid:删除消息队列 ID 为 msqid 的消息队列。
  • -m shmid:删除共享内存段 ID 为 shmid 的共享内存。
  • -s semid:删除信号量集 ID 为 semid 的信号量集。
  • -Q msgkey:删除键为 msgkey 的消息队列。
  • -M shmkey:删除键为 shmkey 的共享内存段。
  • -S semkey:删除键为 semkey 的信号量

针对我们实例程序中的消息队列可以使用,msqid进行删除

1
$ ipcrm -q 0

运行上述命令之后再进行查看,创建的消息队列被删除:

image-20231229190053593



5.消息队列-ftp实例

使用消息队列在接收端与发送端之间进行数据的收发

实例描述:

Client端,向Server端发送path请求,path表示的是文件路径

Server端,接收Client的path请求,根据path请求,找到path路径下对应的数据

Server端,将path下的数据分为多份data包发送给Client端,若数据发送完毕,则发送EOF(End of transmission)数据包告知Client发送完毕

图解如下:

image-20240109110829968

(1)收发协议

两种方式实现,Client端不同数据包的判断

proto.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
//
// 协议1:使用共用体 判断Client端接收的数据包种类
//

#ifndef MYFTP_MSG_PROTO_H
#define MYFTP_MSG_PROTO_H

#define KEYPATH "./Communication_Service" // ftok 函数中使用的pathname ftok 使用路径
#define KEYPROJ 'g' // ftok 函数中使用的proj_id 项目标识符 g 生成一个进程通信的键

#define PATHMAX 1024
#define DATAMAX 1024

// 使用枚举数据表示 判断接收到包的种类
enum{
MSG_PATH = 1,
MSG_DATA,
MSG_EOT
};


// path 包
// Server 端接收 path包
typedef struct msg_path_st
{
long mtype; /* 必须是 MAG_PATH */
char path[PATHMAX]; /* ASCIIZ字符串 字符串的末尾由一个空字符(ASCII码为0,通常写作 \0)标记 */
}msg_path_t;

// 数据包
// Client 端 可能接收的包
typedef struct msg_data_st
{
long mtype; /* 必须是 MAG_DATA */
char data[DATAMAX];
int datalen;
}msg_data_t;

// eot包(代表接收数据结尾,此时已经没有接收的数据)
// Client 端 可能接收的包
typedef struct msg_eot_st
{
long mtype; /* 必须是 MAG_EOT */
}msg_eot_t;


// 使用共用体对象 判断 Client 接收的包的类型
// 共用体中以下三种类型数据只会存在一个
// 以下三种数据类型 均会占用同一段内存空间
// 而 long mtype 占用内存中前四个字节 表示的则是 接收的数据包的种类
union msg_s2c_un
{
long mtype;
msg_data_t datamsg;
msg_eot_t etomsg;
};



#endif //MYFTP_MSG_PROTO_H

proto2.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
//
// 协议2:不使用共用体 判断Client端接收的数据包种类
//

#ifndef MYFTP_MSG_PROTO2_H
#define MYFTP_MSG_PROTO2_H

#define KEYPATH "./Communication_Service" // ftok 函数中使用的pathname ftok 使用路径
#define KEYPROJ 'g' // ftok 函数中使用的proj_id 项目标识符 g 生成一个进程通信的键

#define PATHMAX 1024
#define DATAMAX 1024

// 使用枚举数据表示 判断接收到包的种类
enum{
MSG_PATH = 1,
MSG_DATA,
MSG_EOT
};


// path 包
// Server 端接收 path包
typedef struct msg_path_st
{
long mtype; /* 必须是 MAG_PATH */
char path[PATHMAX]; /* ASCIIZ字符串 字符串的末尾由一个空字符(ASCII码为0,通常写作 \0)标记 */
}msg_path_t;

// 数据包
// Client 端 接收的包
typedef struct msg_data_st
{
long mtype; /* 必须是 MAG_DATA 或者 MSG_EOT*/
int datalen;
/*
* datalen > 0 : 接收的是data包
* datalen ==0 : 接收的是eot包(表示接收数据的结尾 接收数据完毕)
* */
char data[DATAMAX];

}msg_data_t;

#endif //MYFTP_MSG_PROTO2_H


6.消息队列-信号量数组实例

信号量数组是一种在并发编程中使用的同步机制,它通常用于控制对共享资源的访问。在Unix和类Unix系统中,信号量通常是通过系统调用和IPC(进程间通信)机制实现的。信号量数组就是包含多个信号量的集合,允许程序同时管理多个共享资源。

若信号量数组,的大小为1则表示信号量或者互斥锁,对于单个资源的单独共享

基本概念

  • 信号量(Semaphore):一个整数值,用于同步进程或线程,控制对共享资源的访问。
  • 信号量数组:包含多个信号量的集合,每个信号量可以独立地用来控制对不同共享资源的访问。

主要操作

  • 初始化:在使用信号量之前,需要初始化它们,包括设置初始值。
  • 等待(P操作或wait):如果信号量的值大于0,减少它的值并继续执行。如果信号量的值为0,进程或线程将阻塞,直到信号量值变为正。
  • 发信号(V操作或signal):增加信号量的值。如果有其他进程或线程因信号量的值为0而阻塞,它们中的一个将被唤醒

使用场景

信号量数组在多进程环境中特别有用,特别是当你需要控制对多个不同资源的并发访问时。例如,你可能有一个包含多个文件或数据结构的系统,每个文件或数据结构都需要单独的同步机制

(1)信号量集合相关API
semget

semget 函数是 UNIX 和类 UNIX 系统(如 Linux)中用于创建新的信号量集或访问现有信号量集的系统调用。它是实现进程间同步和互斥的关键组件之一

函数原型

1
2
3
4
5
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

int semget(key_t key, int nsems, int semflg);

参数说明

  • key:一个键值(key_t 类型),用于标识信号量集。可以是一个显式的键值或使用 IPC_PRIVATE 创建一个新的私有集合。
  • nsems:需要的信号量数目。如果访问现有的信号量集,这个值通常设置为 0。
  • semflg:一个整数,指定一组标志。这些标志包括创建信号量集的权限(如 06000666 等)和其他选项,如 IPC_CREAT(创建新信号量集,如果已存在则访问它)和 IPC_EXCL(与 IPC_CREAT 结合使用,确保创建新的集合)

返回值

  • 成功时,返回信号量集的标识符(非负整数)。
  • 出错时,返回 -1,并设置 errno 以指示错误的原因(例如 EEXIST 表示信号量集已存在,ENOENT 表示不存在等)。
semop

semop 函数是 UNIX 和类 UNIX 系统(比如 Linux)中用于执行操作(如等待和信号)在信号量上的系统调用。这个函数允许进程对一个或多个信号量执行一系列操作,这对于进程间同步和互斥是非常重要的

函数原型

1
2
3
4
5
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

int semop(int semid, struct sembuf *sops, size_t nsops);

参数说明

  • semid:由 semget 返回的信号量集标识符。
  • sops:指向 sembuf 结构数组的指针,每个元素指定对单个信号量执行的操作。
  • nsopssops 数组中的元素数量,表示要执行的操作数。

sembuf 结构定义如下:

1
2
3
4
5
struct sembuf {
unsigned short sem_num; /* 信号量集中信号量的索引 */
short sem_op; /* 操作类型:-1(等待)、0(测试)、+1(信号) */
short sem_flg; /* 操作标志,如 IPC_NOWAIT */
};

返回值

  • 成功时,返回 0。
  • 出错时,返回 -1,并设置 errno 以指示错误的原因(例如 EINTR 表示操作被中断,EINVAL 表示参数无效等)。
semctl

semctl 函数是 UNIX 和类 UNIX 系统(如 Linux)中用于控制信号量集的系统调用。它提供了一种方式来执行多种不同的控制操作,比如设置信号量的值,获取信号量的当前值,或删除信号量集

函数原型

1
2
3
4
5
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

int semctl(int semid, int semnum, int cmd, ...);

参数说明

  • semid:由 semget 返回的信号量集标识符。
  • semnum:信号量集中的信号量索引(对于某些命令,这个参数可能被忽略)。
  • cmd:控制操作的类型,如 SETVAL(设置信号量的值),GETVAL(获取信号量的当前值),IPC_RMID(删除信号量集)等。
  • **…**:附加参数,根据 cmd 的不同而变化。例如,对于 SETVAL,它是一个 union semun 类型的值,用于设置信号量的值。

共用体uniom semum定义
由于历史原因,union semun 并不总是在系统头文件中定义。因此,你可能需要自己定义它,如下所示

1
2
3
4
5
union semun {
int val; /* 用于 SETVAL */
struct semid_ds *buf; /* 用于 IPC_STAT 和 IPC_SET */
unsigned short *array; /* 用于 GETALL 和 SETALL */
};

返回值

  • 成功时,根据执行的命令不同,返回值也不同。例如,GETVAL 返回信号量的当前值。
  • 出错时,返回 -1,并设置 errno 以指示错误的原因(例如 EINVAL 表示参数无效,EACCES 表示权限错误等)。
(2)实例程序

使用信号量数组,实现互斥锁,保证对于单个进程共享资源的单独使用,进程同步

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
/*
* 多进程并发
* */
#include <stdio.h>
#include <stdlib.h>

#include <string.h>
#include <unistd.h>
#include <sys/wait.h>

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

#include <errno.h>


#define PROCNUM 20
#define FNAME "../out"
#define LINESIZE 1024

static int semid;

// p函数是取出资源进行使用,因此信号量中资源数进行-1操作 op.sem_op = -1
static void P(void)
{
struct sembuf op;
op.sem_num = 0; /* 信号量集中信号量的索引 */
op.sem_op = -1; /* 操作类型:-1(等待)、0(测试)、+1(信号) */
op.sem_flg = 0; /* 操作标志,如 IPC_NOWAIT */

// 对信号量进行操作
/*
* 参数1:由semget返回的信号量集标识符
* 参数2:指向sembuf结构数组的指针,每个元素指定对单个信号量执行的操作
* 参数3:sops数组中的元素数量,表示要执行的操作数
* */
while(semop(semid,&op,1)<0)
{
// 判断是否为真的错误,因为semop是一个阻塞的系统调用
if(errno != EINTR || errno != EAGAIN)
{
perror("semop()");
exit(1);
}
}
}

// v函数是放回资源进行使用,因此信号量中资源数进行+1操作 op.sem_op = 1
static void V(void)
{
struct sembuf op;
op.sem_num = 0;
op.sem_op = 1;
op.sem_flg = 0;

// 对信号量进行操作
/*
* 参数1:由semget返回的信号量集标识符
* 参数2:指向sembuf结构数组的指针,每个元素指定对单个信号量执行的操作
* 参数3:sops数组中的元素数量,表示要执行的操作数
* */
if(semop(semid,&op,1)<0)
{
perror("semop()");
exit(1);
}
}

// 线程函数
static void func_add(void)
{
FILE *fp;
char linebuf[LINESIZE];

// 打开文件
// fopen用于将文件流(例如,由 fopen 创建的 FILE* 类型的对象)转换成对应的文件描述符
fp = fopen(FNAME,"r+");
if(fp ==NULL)
{
perror("fopen()");
exit(1);
}


// PV操作是进行资源取还的两个操作
// 取资源时,进行P()操作
P();

// 读取文件中的一行
fgets(linebuf,LINESIZE,fp);
// 定位到文件开始的位置进行覆盖写入
fseek(fp,0,SEEK_SET);
sleep(1);
// 将linfbuf字符串中的转为整形+1 在写入文件流fp中
fprintf(fp,"%d\n",atoi(linebuf)+1);
// fptintf为行缓冲模式,而文件是全缓冲模式,需要刷新流
fflush(fp);

// 归还资源量,进行V() 操作
V();

fclose(fp);

return;
}

int main()
{
int i;
pid_t pid;

// 重点,该程序是父子进程进行通信,因此可以不使用ftok函数,当然也可以进行使用
// 创建一个用于进程间进行通信的key值
// ftok();
// 该程序用于父子进程间的通信,父进程先使用ftok可以创建一个唯一的key值
// 使用fork函数创建子进程,则每个子进程均可以得到一个IPC的key值
// (重点)因此下面的semget函数传入的key_t key可以不用找一个合适的key值,可以直接使用参数IPC_PRIVATE,创建一个新的、私有的消息队列
// 则上面的ftok函数就可以不使用了

// 创建信号量数组实例
/*
* 参数1:是一个IPC键,通常由ftok函数生成。特殊值IPC_PRIVATE可用于创建一个新的、私有的消息队列
* 参数2:需要的信号量数目。如果访问现有的信号量集,这个值通常设置为 0
* 参数3:一个整数,指定一组标志。这些标志包括创建信号量集的权限(如0600或0666等)和其他选项
* 如IPC_CREAT(创建新信号量集,如果已存在则访问它)和IPC_EXCL(与IPC_CREAT结合使用,确保创建新的集合)
* */
semid = semget(IPC_PRIVATE,1,IPC_CREAT|0600);
if(semid < 0)
{
perror("semget()");
exit(1);
}

// 对信号量数组进行初始化
/*
* 参数1:由semget返回的信号量集标识符
* 参数2:信号量集中的信号量索引
* 参数3:控制操作的类型,如SETVAL(设置信号量的值),GETVAL(获取信号量的当前值),IPC_RMID(删除信号量集)等
* 参数4:附加参数,根据 cmd的不同而变化。例如,对于SETVAL,它是一个 union semun类型的值,用于设置信号量的值。
* */
// 此处设置信号量的值为1,表示只有一个信号量可以用
// union semun arg;
// arg.val = 1; union semun,已经在 <sys/sem.h> 中定义
if(semctl(semid,0,SETVAL,1) < 0)
{
perror("semctl()");
exit(1);
}


for(i = 0;i<PROCNUM;i++)
{
pid = fork();
// 进程创建失败
if(pid < 0)
{
perror("fork()");
exit(1);
}
// 子进程
if(pid == 0)
{
func_add();
exit(0);
}
}


// 回收线程资源(收尸)
for(i = 0;i<PROCNUM;i++)
{
wait(NULL);
}

// 销毁信号量数组实例
semctl(semid,0,IPC_RMID);

exit(0);
}

上述代码,可以实现多进程对out文件逐次递加使用



7.进程间通信-共享内存项目实例

共享内存是一种进程间通信(IPC)机制,允许两个或多个进程共享一个给定的存储区。它是最快的IPC方法之一,因为进程是直接对内存进行读写,而不是通过操作系统进行数据传输

如何工作

在共享内存模型中,操作系统为多个进程分配一块内存区域。一旦这块内存被分配,所有有访问权限的进程都可以直接读写这块内存。这些进程可能是同时运行的,并且可以看到内存中的即时更新。

共享内存优点

  • 效率高:由于数据不需要在进程之间复制,所以这是一种非常快速的数据交换方式。
  • 实时通信:共享内存允许进程以几乎无延迟的方式进行通信。

共享内存优点

  • 同步问题:当多个进程需要访问共享内存时,同步变得至关重要。否则,就可能出现竞争条件和数据不一致的问题。

  • 复杂性:管理共享内存,特别是同步和协调访问,比其他IPC机制更复杂。

(1)共享内存相关API
shmget

shmget 是 UNIX 和类 UNIX 系统(比如 Linux)中用于创建新的共享内存段或获取现有共享内存段的标识符的系统调用。共享内存是一种高效的进程间通信(IPC)机制,允许多个进程访问同一块内存区域

1
2
3
4
5
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>

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

参数说明

  • key:一个键值(key_t 类型),用于标识共享内存段。可以是一个显式的键值或使用 IPC_PRIVATE 创建一个新的私有内存段。
  • size:需要的共享内存大小(以字节为单位)。
  • shmflg:一个整数,指定一组标志。这些标志包括创建共享内存段的权限(如 06000666 等)和其他选项,如 IPC_CREAT(创建新共享内存段,如果已存在则访问它)和 IPC_EXCL(与 IPC_CREAT 结合使用,确保创建新的段)

返回值

  • 成功时,返回共享内存段的标识符(非负整数)。
  • 出错时,返回 -1,并设置 errno 以指示错误的原因(例如 EEXIST 表示共享内存段已存在,ENOENT 表示不存在等)
shmat

shmat(shared memory attach)函数是 UNIX 和类 UNIX 系统中用于将共享内存段附加到进程的地址空间的系统调用。这个函数使进程能够访问共享内存段中的数据

函数原型

1
2
3
4
#include <sys/types.h>
#include <sys/shm.h>

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

参数说明

  • shmid:由 shmget 返回的共享内存段标识符。
  • shmaddr:指定共享内存连接到进程地址空间的特定地址。如果为 NULL,则操作系统选择地址。
  • shmflg:附加标志。常用的标志包括 SHM_RND(用于舍入 shmaddr 到系统允许的地址边界)和 SHM_RDONLY(将内存段附加为只读)

返回值

  • 成功时,返回指向共享内存段的指针。
  • 出错时,返回 ((void *) -1),并设置 errno 以指示错误的原因。
shmdt

shmdt 函数是 UNIX 和类 UNIX 系统(如 Linux)中用于将共享内存段从进程的地址空间分离的系统调用。当进程完成对共享内存的访问并希望解除与该内存段的关联时,它会使用此函数

函数原型

1
2
3
4
#include <sys/types.h>
#include <sys/shm.h>

int shmdt(const void *shmaddr);

参数说明

  • shmaddr:指向共享内存段的指针,这个指针是之前调用 shmat 时返回的

返回值

  • 成功时,返回 0
  • 出错时,返回 -1,并设置 errno 以指示错误的原因。
shmctl

shmctl 是 UNIX 和类 UNIX 系统(如 Linux)中用于执行共享内存控制操作的系统调用。这个函数允许你对共享内存进行多种操作,如获取状态信息、修改权限、标记删除等

函数原型

1
2
3
4
5
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>

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

参数说明

  • shmid:共享内存段的标识符,通常由 shmget 返回。
  • cmd:控制命令,决定了 shmctl 的行为。常用命令包括 IPC_STAT(获取共享内存状态)、IPC_SET(设置共享内存参数)和 IPC_RMID(标记共享内存以便删除)。
  • buf:指向 shmid_ds 结构的指针,用于获取或设置共享内存的状态信息。结构定义如下:
1
2
3
4
5
6
7
8
9
10
struct shmid_ds {
struct ipc_perm shm_perm; // 所有权和权限
size_t shm_segsz; // 大小(字节)
time_t shm_atime; // 最后一次附加时间
time_t shm_dtime; // 最后一次分离时间
time_t shm_ctime; // 最后一次修改时间
pid_t shm_cpid; // 创建者的进程ID
pid_t shm_lpid; // 最后操作的进程ID
shmatt_t shm_nattch; // 当前附加的进程数
};

返回值

  • 成功时,返回 0
  • 出错时,返回 -1,并设置 errno 以指示错误的原因。

注意

  • 使用 IPC_RMID 命令标记共享内存以便删除后,该内存段仍然存在,直到所有附加的进程都分离(即 shm_nattch 为0)。
  • 在共享内存段被删除之前,它仍然可以被其他进程附加和使用。
  • 适当地管理共享内存非常重要,特别是在多进程系统中,以防止资源泄露。

(2)实例程序

使用共享内存用于父子进程间的通信

描述:子进程向共享内存中写入数据HELLO,父进程读取共享内存中的数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#include <stdio.h>
#include <stdlib.h>

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>
#include <string.h>
#include <wait.h>

#define MEMSIZE 1024

int main()
{
int shmid;
pid_t pid;
char *ptr;

// ftok 获得进程间通信的key值
// 实现父子进程通信则可以不使用这个函数
// 在shmget函数中使用参数`IPC_PRIVATE`创建一个新的私有内存段
// ftok();

// 创建共享内存实例
// 返回一个shmid,共享内存ID
shmid = shmget(IPC_PRIVATE,MEMSIZE,IPC_CREAT|0600);
if(shmid < 0)
{
perror("shmget()");
exit(1);
}

// 创建子进程
pid = fork();
if(pid < 0)
{
perror("fork()");
exit(1);
}

if( pid == 0)
{
// 子进程操作--对共享内存进行写入数据
// 将共享内存段附加到进程的地址空间。使进程能够访问共享内存段中的数据
ptr = shmat(shmid,NULL,0);
if(ptr == (void *)-1)
{
perror("shmat()");
exit(1);
}
// 对共享内存 char *ptr写入数据
strcpy(ptr,"HELLO!\n");
// 解除共享内存的映射
shmdt(ptr);
// 退出子进程
exit(0);

}else{
// 阻塞进程,对子进程资源进行收尸
wait(NULL);
// 父进程操作--对共享内存进行读取数据
// 将共享内存段附加到进程的地址空间。使进程能够访问共享内存段中的数据
ptr = shmat(shmid,NULL,0);
if(ptr == (void *)-1)
{
perror("shmat()");
exit(1);
}
// 读取 char *ptr 指针指向的共享内存
puts(ptr);
// 解除共享内存的映射
shmdt(ptr);
// 销毁共享内存实例
shmctl(shmid,IPC_RMID,NULL);
// 退出父进程
exit(0);
}

}

运行结果

image-20240109162331930




三、套接字

1.跨主机传输要注意的主要问题

(1)基础知识
大小端存储

大小端存储(Big-endianLittle-endian)是计算机系统中用于存储数据字节序的两种不同方式。它们主要涉及多字节数据(如整数、浮点数)在内存中的存储顺序

  • 大端模式

    在大端模式中,最高有效字节(MSB)存储在最低的内存地址上,而最低有效字节(LSB)存储在最高的内存地址上(低地址放高字节

    例如,一个值为 0x12345678 的四字节整数(字节序,两个16进制位(等价于8个二进制位)表示一个字节),在内存中的存储顺序(从低地址到高地址)将是 12 34 56 78

​ 大端模式可视作更接近人类阅读和书写数字的方式

  • 小端模式:

    在小端模式中,最低有效字节(LSB)存储在最低的内存地址上,而最高有效字节(MSB)存储在最高的内存地址上(低地址放低字节

    同样的例子,值 0x12345678 的四字节整数,在内存中的存储顺序将是 78 56 34 12

    小端模式在许多现代计算机系统中(如x86架构)更为常见

注意(重点理解)

  • 0x12345678 中,0x12 始终是最高有效字节,而 0x78 始终是最低有效字节(LSB

  • 在十六进制(16进制)系统中,每位可以表示16个不同的值,范围从0到15。然而,为了在数字表示中包含超过9的值,十六进制使用字母A到F来代表10到15。因此,十六进制的每位可以是0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E, 或 F

    所以,当你看到一个像 0x12345678 这样的十六进制数时,每个数字或字母都代表一个单独的十六进制位。这个数分解如下:

    1, 2, 3, 4, 5, 6, 7, 8 每个都是单独的十六进制位

    12, 34, 56, 78 每对数字代表一个字节,因为在十六进制中,每两位组成一个8位(或1字节)的值(在十六进制(16进制)中,每两位组成一个8位(或1字节)的值,这是因为每个十六进制位可以表示4个二进制位,而两个16进制位则是8个二进制位,即一个字节)

32位机器与64位机器数据类型存储字节数
  • int类型

    在32位的机器环境中,int 类型的数据通常占用4个字节。这是因为在大多数32位系统中,标准的整型(int)被定义为32位,而每个字节包含8位,因此 32位 / 8位/字节 = 4字节

    在64位的机器环境中,int 类型数据的大小并不总是因为系统是64位的就自动扩展到64位。实际上,int 类型的大小取决于编译器和操作系统的具体实现,但在大多数现代的64位系统中,int 仍然保持为32位,即4个字节。这是为了保持与旧代码的兼容性。不过,其他一些数据类型,如指针(pointers)和长整型(long),在64位系统中可能会增加到64位


(2)字节序问题

跨主机通信时字节序问题是计算机网络中的一个重要考虑因素。不同的计算机系统可能采用不同的字节序(即大小端模式)来存储数据,这可能导致在一个系统中正确表示的数据,在另一个系统中被错误解释。

机器对于文件数据按地址位读取方式:数据通常是按照它们在文件中的顺序逐字节读取的。这意味着读取操作通常是从文件的开始(低地址位)向文件的结束(高地址位)进行的

若在跨主机通信中,收发两端采用不同的字节序来存储数据,则解析数据时会存在问题

问题根源:

  • 不同的字节序:有些系统采用大端模式(如大多数网络设备和一些旧的计算机系统),而其他系统则采用小端模式(如大多数现代个人电脑和服务器)。
  • 直接传输未转换的数据:如果一个系统直接将其内部表示的数据发送到另一个采用不同字节序的系统,接收方可能会错误地解释这些数据。

解决方法

  • 网络字节序:为了解决这个问题,网络通信通常使用一种称为“网络字节序”的标准,它是大端模式。因此,在数据在网络上发送之前,它通常被转换为网络字节序。接收系统需要将接收到的数据从网络字节序转换回其本地(主机)字节序
  • 明确协议规范:通信协议应该明确指定所使用的字节序;在设计协议时,应该考虑到不同系统间的字节序差异,并在必要时进行适当的转换
  • 使用无关字节序的格式:另一种方法是使用格式(如JSONXML)来交换数据,这些格式本质上是字节序无关的,因为它们表示的是文本数据
  • 转换函数:在需要时,开发者可以使用如 htonl()htons()(host-to-network long/short)和它们的反向函数 ntohl()ntohs()network-to-host long/short)等函数来在本地字节序和网络字节序之间进行转换
网络字节序

网络字节序是一种在网络上发送和接收数据时使用的字节序约定。由于不同的计算机系统可能使用不同的字节序(即大端模式和小端模式),因此在通过网络通信时,需要一个共同的标准以确保数据的一致性和正确性。网络字节序就是这样一个标准,它采用大端模式

网络字节序特点

  • 使用大端模式
  • 跨平台一致性:采用网络字节序可以确保来自不同字节序计算机系统的数据在网络上传输时保持一致的表示方式

应用

  • 协议遵守:许多网络协议,如TCP/IP,明确规定使用网络字节序来表示多字节数值。因此,构建和解析网络数据包时需要遵循这一约定
  • 数据转换:在发送数据之前,发送方可能需要将其本地字节序的数据转换为网络字节序。接收方收到数据后,可能需要将网络字节序的数据转换回其本地字节序。这通常通过函数(如 htonl()htons()ntohl()ntohs() 等)完成,这些函数在不同平台上有不同的实现,但提供一致的接口。这些函数中h表示host主机的意思,to表示从前者到后者的意思,而n表示network,最后的s与l则表示shortlong类型的数据

(3)结构体对齐问题

在数据存储中,为了加速地址检索,结构体会存在地址对齐,但是在网络通信的结构体中需禁止对齐(指定告知编译器不需要进行地址对齐)

结构体对齐方式


(4)数据类型长度问题

例如:不同机器之间int占多少字节,占多少二进制位。char字符数据,有无符号

解决方法

  • 使用通用的类型数据:如32位的整型数,int32_t;32位无符号整型数据,uint32_t;类推,int64_t,int8_t,uint8_t


2.套接字-Socket

Socket(套接字)是一种网络通信的端点,它为网络中的不同主机之间提供了一种发送和接收数据的方式。Socket是计算机网络通信的基础,存在于许多不同类型的网络协议中,最常见的是基于TCP/IP协议的Socket。在应用层和网络层之间,套接字充当了一个接口的角色

套接字关键特点

  • 通信端点:Socket是网络通信的端点,每个Socket都有一个对应的IP地址和端口号
  • 支持不同的通信类型:套接字可以用于不同类型的网络通信,如TCP(传输控制协议)和UDP(用户数据报协议)
  • 面向连接和无连接:TCP套接字是面向连接的,意味着在数据传输之前需要建立一个稳定的连接;UDP套接字是无连接的,它们发送独立的数据包而不需要建立稳定的连接
  • 数据传输:套接字允许应用程序通过网络发送和接收数据
(1)套接字相关API
socket()

socket() 函数用于创建一个新的套接字,它是网络通信的基本操作之一。这个函数定义了套接字的类型、所使用的协议族以及具体的协议类型。它的原型如下:

1
2
3
4
#include <sys/types.h>
#include <sys/socket.h>

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

参数说明

  • domain:指定套接字使用的协议族,常见的有 AF_INET(表示IPv4网络协议)、AF_INET6(表示IPv6网络协议)等
  • type:指定套接字的类型。常见的类型有 SOCK_STREAM(表示面向连接的套接字,通常用于TCP)和 SOCK_DGRAM(表示无连接的套接字,通常用于UDP)
  • protocol:指定具体的协议。通常设置为0,表示选择默认协议(对于 SOCK_STREAM 是TCP,对于 SOCK_DGRAM 是UDP)

返回值

  • 成功时,返回一个套接字描述符(一个正整数)。
  • 失败时,返回-1,并设置 errno 以指示错误原因
bind()

bind() 函数用于将套接字与特定的IP地址和端口号绑定。它通常用于服务器端的套接字,以便指定和监听来自客户端的连接请求

函数原型

1
2
3
4
#include <sys/types.h>
#include <sys/socket.h>

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfd:是由 socket() 函数调用返回的套接字文件描述符。
  • addr:是指向 struct sockaddr 结构的指针,该结构包含了要绑定的IP地址和端口号。
  • addrlen:指定了 addr 结构的大小。

返回值

  • 成功:当 bind() 函数成功绑定套接字到指定的地址和端口时,它返回 0
  • 失败:如果出现错误,bind() 函数返回 -1。此外,全局变量 errno 被设置为一个特定的错误代码,以提供有关错误的更多信息

使用步骤

  • 创建一个套接字使用 socket()
  • 填充 struct sockaddr 结构(或其它相关结构,如 struct sockaddr_in 对于IPv4)。
  • 调用 bind() 将套接字与指定的IP地址和端口号绑定。
struct sockaddr

在C语言中用于网络编程的 struct sockaddr 是一个通用的地址结构,用于处理各种类型的套接字地址。这个结构通常与专用的地址结构一起使用,如 struct sockaddr_in(用于IPv4地址)和 struct sockaddr_in6(用于IPv6地址)。由于历史原因和向后兼容性,struct sockaddr 被广泛用于套接字函数中,如 bind(), connect(), accept() 等。

struct sockaddr的定义

struct sockaddr<sys/socket.h> 头文件中定义,如下所示:

1
2
3
4
struct sockaddr {
sa_family_t sa_family; // 地址族(AF_INET, AF_INET6, AF_UNIX 等)
char sa_data[14]; // 14字节,足以容纳任何类型的地址
};

为什么使用struct sockaddr

struct sockaddr 本身并不常直接用于存储地址信息,因为它不提供足够的空间来存储具体的地址详情。相反,更具体的结构struct sockaddr_in 用于IPv4地址,而 struct sockaddr_in6 用于IPv6地址。这些结构包含了特定于协议的地址信息,如IP地址和端口号

当调用套接字函数时,这些特定的地址结构被转换struct sockaddr 类型的指针,以便提供一个通用的接口。例如,bind() 函数接受 struct sockaddr * 类型的参数,尽管实际传递的可能是 struct sockaddr_in * 类型的地址

struct sockaddr_in的定义

对于IPv4网络编程,通常使用 struct sockaddr_in,它在 <netinet/in.h> 中定义,如下所示:

1
2
3
4
5
6
struct sockaddr_in {
short sin_family; // 地址族,AF_INET
unsigned short sin_port; // 端口号
struct in_addr sin_addr; // IPv4地址
char sin_zero[8]; // 填充以保持与struct sockaddr相同的大小
};

其中 struct in_addr 结构仅包含一个无符号长整型的IP地址。

struct in_addr 类型的 sin_addr 成员用于存储IPv4网络地址。struct in_addr 是专门设计来容纳IPv4地址的结构

struct in_addr 的定义

<netinet/in.h> 头文件中,struct in_addr 通常被定义如下:

1
2
3
4
struct in_addr {
uint32_t s_addr; // 32位IPv4地址
};

s_addr 是一个无符号的32位整数,用于存储IPv4地址。在网络通信中,IPv4地址通常以网络字节序(大端)存储。s_addr 是一个无符号的32位整数,用于存储IPv4地址。在网络通信中,IPv4地址通常以网络字节序(大端)存储。

使用struct in_addr

在使用 struct sockaddr_in 结构时,你通常会通过操作 sin_addrs_addr 成员来指定IPv4地址。以下是一些常见的用法:

  • 指定任意地址:使用 INADDR_ANY,它通常被定义为 0.0.0.0,这表示套接字可以绑定到机器上的任何IP地址

    1
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
  • 指定具体的IP地址:使用 inet_addr() 函数将点分十进制的IP地址字符串转换为合适的格式

    1
    addr.sin_addr.s_addr = inet_addr("192.168.1.1");

    或者使用 inet_aton() 函数,这是一个更健壮的选择

    1
    2
    3
    4
    struct sockaddr_in addr;
    struct in_addr ip;
    inet_aton("192.168.1.1", &ip);
    addr.sin_addr = ip;
inet_addr()

inet_addr() 函数用于将一个点分十进制的IPv4地址字符串转换为一个网络字节序的整数值。这个函数是处理IPv4地址的传统方法,但它已经被更现代和灵活的 inet_pton() 函数所取代。尽管如此,inet_addr() 仍然在许多程序中使用

inet_addr() 函数的原型定义在 <arpa/inet.h> 头文件中,如下所示:

1
2
3
#include <arpa/inet.h>

in_addr_t inet_addr(const char *cp);
  • cp:指向以零结尾的字符数组的指针,这个数组包含了一个点分十进制的IP地址(如 “192.168.1.1”)。

返回值

  • 成功时,函数返回一个无符号的32位整数,表示IPv4地址的网络字节序。
  • 失败时,如果输入不是有效的IPv4地址,它返回 INADDR_NONE,通常等于 -1(或 0xFFFFFFFF)。
inet_pton()

inet_pton() 函数是用于将点分十进制的IP地址(如 “192.168.1.1”)转换为网络字节序的二进制形式的函数。这个函数是 inet_addr() 的一个更现代和通用的替代,它支持IPv4和IPv6地址

inet_pton() 函数的原型定义在 <arpa/inet.h> 头文件中,如下所示:

1
2
3
#include <arpa/inet.h>

int inet_pton(int af, const char *src, void *dst);
  • af:指定地址族,AF_INET 用于IPv4地址,AF_INET6 用于IPv6地址。
  • src:指向要转换的以零结尾的字符数组(即C字符串)的指针,这个数组包含了一个点分十进制的IP地址(对于IPv4)或一个十六进制的IP地址(对于IPv6)。
  • dst:指向一个足够大的缓冲区的指针,该缓冲区用于存储转换后的网络地址。对于IPv4,这通常是一个 struct in_addr;对于IPv6,这通常是一个 struct in6_addr

返回值

  • 返回 1 表示转换成功。
  • 返回 0 表示 src 不包含有效的IP地址。
  • 返回 -1 表示发生错误,errno 会设置为相应的错误代码。
inet_ntop()

inet_ntop 函数是一种在C语言中用于网络编程的函数,它用于将网络地址(如IPv4或IPv6地址)从其数值形式转换为标准文本形式(如点分十进制格式的IPv4地址或冒号分隔格式的IPv6地址)。与旧的 inet_ntoa 函数相比,inet_ntop 提供了更大的灵活性和安全性,它支持IPv6,并且是线程安全的。

函数原型:

1
2
3
#include <arpa/inet.h>

const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

参数说明:

  • af: 地址族,AF_INET 用于IPv4地址,AF_INET6 用于IPv6地址。
  • src: 指向一个包含要转换的网络地址的缓冲区的指针。
  • dst: 指向目的缓冲区的指针,这里将存储转换后的文本地址。
  • size: 目的缓冲区的大小

返回值:

如果函数成功,返回一个指向结果字符串的指针。如果发生错误,则返回 NULL

注意

使用 inet_ntop 时,您需要提供足够大的缓冲区来存储转换后的地址。对于IPv4,这个缓冲区应至少为16个字符的长度(包括终止空字符)。对于IPv6,建议的最小大小是46个字符。

listen()

listen 函数的作用是启动监听来自客户端的连接请求

函数原型

1
2
3
#include <sys/types.h> 
#include <sys/socket.h>
int listen(int sockfd, int backlog);

参数:

  • sockfd:这是由之前调用 socket 函数得到的套接字文件描述符。
  • backlog:这个参数指定了队列中最多可以容纳的等待接受的连接请求的数量。它决定了同时可以有多少个客户端处于连接等待状态。

返回值:

  • 如果函数调用成功,它会返回0。
  • 如果调用失败,会返回-1,并设置相应的错误码,可以通过 errno 获取错误详情。

使用流程

  • 创建套接字:使用 socket 函数创建一个套接字
  • 绑定地址:使用 bind 函数将套接字与特定的IP地址和端口号绑定
  • 监听连接:使用 listen 函数开始监听连接请求
  • 接受连接:使用 accept 函数接受来自客户端的连接
accept()

套接字编程的 accept 函数用于服务器端接受来自客户端的连接请求。一旦 listen 函数在服务器端套接字上启动了对连接请求的监听,accept 函数就可以用来接受这些请求

函数原型

1
2
3
#include <sys/types.h> 
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

参数:

  • sockfd:这是服务器端监听的套接字的文件描述符,之前由 socket 函数创建并通过 bindlisten 函数设置
  • addr:这是一个指向 struct sockaddr 的指针,用于接收一个返回值,表示连接到服务器的客户端的地址。该参数可以设置为 NULL,如果你不需要获取客户端地址。
  • addrlen:这是一个指向 socklen_t 类型变量的指针,初始时应该设置为指向一个值,表示 addr 结构的大小。函数返回时,这个值会被更新为实际接收到的地址的大小。如果 addrNULLaddrlen 也应该是 NULL

返回值:

  • 成功时返回一个新的套接字文件描述符,用于与连接的客户端进行通信。
  • 失败时返回-1,并设置 errno 以指示错误原因
connect()

接字编程的 connect 函数用于客户端与服务器端建立连接。这个函数在客户端套接字上调用,指定服务器的地址和端口,以便建立到服务器的连接。

函数原型

1
2
3
#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数;

  • sockfd:这是一个套接字文件描述符,之前由 socket 函数创建。
  • addr:这是一个指向 struct sockaddr 的指针,它包含了目的地(服务器)的地址和端口信息。对于IPv4,通常使用 struct sockaddr_in 来设置该参数,并将其转换为 struct sockaddr 类型的指针进行传递。
  • addrlen:这是 addr 参数所指向的地址结构的大小

返回值

  • 成功时返回0。
  • 失败时返回-1,并设置 errno 以指示错误原因。
send()—TCP

套接字编程的 send 函数用于向一个已连接的套接字发送数据。这个函数常用于客户端和服务器端的套接字通信中

函数原型

1
2
3
#include <sys/types.h>
#include <sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t len, int flags);

参数:

  • sockfd:这是一个指向已连接套接字的文件描述符。
  • buf:这是一个指针,指向要发送的数据的缓冲区。
  • len:这是要发送的数据的字节数。
  • flags:这是指定调用执行方式的标志位集合。常用的标志包括 MSG_OOB(发送带外数据),MSG_DONTROUTE(不使用路由表),和 MSG_NOSIGNAL(防止发送信号)。在大多数情况下,这个值可以设为0。

返回值:

  • 成功时返回实际发送的字节数。
  • 失败时返回-1,并设置 errno 以指示错误原因。
recv()—TCP

套接字编程的 recv 函数用于从一个已连接的套接字接收数据。这个函数常用于客户端和服务器端的套接字通信中,用于读取从另一端发送过来的数据

函数原型

1
2
3
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

参数原型

  • sockfd:这是一个套接字文件描述符,表示已经建立连接的套接字。
  • buf:这是一个指针,指向用于接收数据的缓冲区。
  • len:这是缓冲区的大小,指定最多可以接收的字节数。
  • flags:这是指定调用执行方式的标志位集合。常用的标志包括 MSG_PEEK(查看数据但不从队列中移除)和 MSG_WAITALL(等待所有请求的数据到达才返回)。在大多数情况下,这个值可以设为0。

返回值

  • 成功时返回接收到的字节数。如果连接已经结束,返回0。
  • 失败时返回-1,并设置 errno 以指示错误原因。
sendto()—UDP

在C语言中,sendto 函数是用于发送数据到特定目的地的网络编程函数,特别是在使用无连接的网络协议(如UDP)时。该函数允许你发送数据到指定的地址和端口,这在UDP通信中是必需的,因为UDP是一种无连接的协议,不会维持一个持久的连接状态。

函数原型:

1
2
3
4
#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: 这是套接字描述符,它引用了一个已经打开的套接字。
  • buf: 指向包含要发送数据的缓冲区的指针。
  • len: 要发送的数据的字节长度。
  • flags: 通常设置为0,但可以指定额外的标志来控制发送行为。
  • dest_addr: 指向包含目的地址(如对方的IP地址和端口)的 struct sockaddr 结构的指针。
  • addrlen: dest_addr 指向的结构的大小。

返回值:

如果函数成功,返回发送的字节数。如果出现错误,则返回-1,并设置相应的错误码。

使用场景

  • UDP套接字: sendto 通常在UDP客户端和服务器中使用,用于向特定的地址和端口发送数据包。

  • 错误处理: 应检查返回值以确定是否成功发送了数据,并相应地处理错误情况。

recvfrom()—UDP

recvfrom 函数是用于从网络套接字接收数据的函数,特别是在使用无连接的网络协议(如UDP)时。这个函数不仅接收数据,还可以获取发送方的地址信息(UDP是无连接的需要记住发送方是谁),这在UDP通信中非常重要,因为UDP是无连接的,不会保持与发送方的持续连接状态。

函数原型:

1
2
3
4
5
#include <sys/types.h>
#include <sys/socket.h>

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);

参数:

  • sockfd: 这是套接字文件描述符,它引用了你想从中接收数据的套接字。

  • buf: 这个指针指向一个缓冲区,该缓冲区用于存储接收到的数据。

  • len: 这是缓冲区的大小,指定了最多可以接收多少字节的数据。

  • flags: 这通常设置为0,但可以设置为控制接收行为的特定标志。

  • src_addr: 这是一个指向 struct sockaddr 结构的指针,用于存储发送方的地址信息。

  • addrlen: 这是一个指向 socklen_t 变量的指针,该变量在调用之前应该被初始化为 src_addr 指向的结构的大小。调用完成后,它将被设置为实际地址结构的大小。

返回值:

  • recvfrom 函数返回接收到的字节数,或者在出错时返回-1。

使用场景

  • UDP套接字: 通常在UDP服务器和客户端中使用recvfrom来接收数据包,因为UDP是无连接的。
  • 错误处理: 通过检查返回值和错误代码来处理可能发生的网络错误。
close()

关闭套接字


(2)特殊地址0.0.0.0

在网络编程中,IP地址 0.0.0.0 有一个特殊的含义。它通常不用作一个特定的目标地址,而是用于表示一个“不指定的”或“任意的”地址。这个地址的具体含义取决于其使用的上下文

  • 在服务器端

    当一个服务器程序绑定到IP地址 0.0.0.0 时,它表明该服务器将接受针对主机的所有IPv4地址的网络连接。这对于多网卡(多接口)的机器特别有用,因为它允许服务器在所有网络接口上监听,而不需要为每个接口指定一个单独的IP地址。

    例如,如果一台服务器有两个网络接口,一个绑定了IP地址 192.168.1.5,另一个绑定了 10.0.0.5,那么绑定到 0.0.0.0 将允许该服务器在两个接口上都接受连接。

  • 在客户端

    在客户端使用 0.0.0.0 时,它通常表示“任意IPv4地址”或“从任何网络接口出去”。然而,实际中,客户端很少使用这个地址,因为在发起网络连接时,通常需要指定一个明确的目的地址。

  • 在路由表中

  • 在路由表中,0.0.0.0 通常用来表示默认路由(也称为“缺省网关”)。这意味着,如果没有为特定目的地找到更具体的路由,数据包将被发送到这个默认路由。

因此,0.0.0.0 在不同的场景中有不同的用途,但总的来说,它代表了一种“任何”或“通用”的概念,在网络配置和编程中具有重要意义。



3.报式套接字(UDP)

报式套接字(Datagram Socket)是一种在网络通信中使用的套接字类型,它提供了无连接的、不可靠的、独立的消息传输。报式套接字通常与用户数据报协议(UDP)一起使用,适用于那些不需要建立连接、可以容忍一定丢包、追求传输效率的场景,如视频会议、在线游戏、实时系统等。

报式套接字主要特性

  • 无连接:数据传输前不需要建立连接;每个数据包(报文)独立发送,彼此之间没有依赖关系
  • 不可靠性:不保证数据包的可靠到达,可能会丢失、重复或乱序;没有确认机制和重传机制
  • 数据有边界:数据包作为独立单元发送,每个包的边界被保留;接收方能够识别出单独的数据包
  • 效率高:由于缺乏连接建立和维持、无需确认和重传机制,使得通信效率较高

创建报式套接字

1
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);

这里,AF_INET 指定使用IPv4地址,SOCK_DGRAM 指定使用报式套接字,0 表示默认协议(通常是UDP)

使用报式套接字

  • 发送数据:使用 sendto() 函数,指定目的地址和端口,将数据包发送出去
  • 接收数据:使用 recvfrom() 函数,从套接字接收数据包

收发双方的步骤

  • 被动端(先收包的一方,先运行):

    取得SOCKET

    给SOCKET取得地址(bind())

    收/发消息

    关闭SOCKET

  • 主动端:

    取得SOCKET

    给SOCKET取得地址(可省略)

    发/收消息

    关闭SOCKET

(1)注意

被动端的第二步,给SOCKET取得地址,无法省略,因为需要告知发送方,自己的IP与端口才可以用于接收信息

主动端的第二步,给SOCKET取得地址,可以省略,若进行省略,则可以得到机器当前空闲的端口进行使用,而ip则是主机ip


(2)报式套接字传输实例
通信协议

使用UDP套接字进行数据通信传输

proto.h

通信协议头文件,在头文件中定义接收方使用的端口,以及通信所使用的数据格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//
// 通信协议
//

#ifndef BASIC_PROTO_H
#define BASIC_PROTO_H

#define RCVPORT "1989" // 做实验尽量使用1024以上的端口,因为这些端口一般都是预留下来的

#define NAMESIZE 11

struct msg_st
{
uint8_t name[NAMESIZE];
uint32_t math;
uint32_t chinese;
}__attribute__((packed));
// __attribute__((packed)) 告知编译器 结构体不进行地址对齐



#endif //BASIC_PROTO_H
接收方源码

rever.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
//
// 接收方
//
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

#include "proto.h"

#define IPSTRSIZE 1024

int main()
{
int sd;
struct msg_st recvbuf; // 用于存储接收的信息结构体
struct sockaddr_in laddr; // 定义一个用于ipv4协议的地址结构体 之后强转为struct sockaddr*,用于bind的第二个参数
struct sockaddr_in raddr; // 用于在recvfrom 函数中存储发送方的地址IP信息
socklen_t raddr_len;
char ipstr[IPSTRSIZE]; // 存储发送方点分式 IP
laddr.sin_family = AF_INET; // IPV4协议族
laddr.sin_port = htons(atoi(RCVPORT)); // 通信协议头文件指定的 端口号,并且字节序要进行转换,从主机字节序转换为网络字节序
// 使用 inet_addr()函数将点分十进制的IP地址字符串转换为合适的格式--无符号的整型数
// 地址可能变化,因此可是选择万能地址0.0.0.0
// laddr.sin_addr.s_addr = inet_addr("0.0.0.0"); // IPV4的地址 32位无符号的整型数---但是实际的IP地址是点分式如:192.168.1.1 因此需要进行使用inet_addr函数进行转换
// 替代上文该句代码
inet_pton(AF_INET,"0.0.0.0",&laddr.sin_addr);



// 取得socket
// 1.AF_INET 为ipv4网络协议族
// 2.SOCK_DGRAM 为报式套接字传输方式,通常用于UDP
// 3.不确定使用什么协议时,可以直接使用参数 0(即报式套接字的默认协议,IPPROTO_UDP)
// 4.返回一个套接字描述符
sd = socket(AF_INET,SOCK_DGRAM,0);
if(sd < 0)
{
perror("socket()");
exit(1);
}


// 将套接字与特定的IP地址和端口号绑定
// 1.函数调用返回的套接字文件描述符
// 2.是指向struct sockaddr结构的指针,该结构包含了要绑定的IP地址和端口号,参数2,可以使用(struct sockaddr *)进行转换但是(void *)是万能的
// 3.指定了addr结构的大小
if(bind(sd,(void *)&laddr,sizeof(laddr)) < 0)
{
perror("bind()");
exit(1);
}

/*!!!*/
/*特别重要需要对socklen_t raddr_len进行初始化,否则第一次通信时,会出错*/
raddr_len = sizeof(raddr);

while(1)
{
// 从Socket上接收信息 -- UDP使用 recvfrom
// 1.socket函数返回的套接字描述符
// 2.接收的信息存储的结构体,在通信协议中有定义
// 3.无特殊要求就填写0
// 4.用于存储发送方的地址IP信息结构体,根据具体协议定义(如:IPV4或者IPV6),最后需要进行强转为(struct sockaddr *)类型
// 5. 参数4存储发送方地址的结构体大小,类型为 socklen_t
recvfrom(sd,&recvbuf,sizeof(recvbuf),0,(void *)&raddr,&raddr_len);
// 打印发送方的地址与端口,端口号需要从网络字节序转换为主机字节序
// 地址需要从32号无符号整数转换为 点分式 192.168.0.0类型 使用函数 inet_ntop函数转换为点分式并且存储至 ipstr中
inet_ntop(AF_INET,&raddr.sin_addr,ipstr,IPSTRSIZE);
printf("---MESSAGE FROM %s:%d---\n",ipstr, ntohs(raddr.sin_port));
// 打印接收的结构体中的信息 name 是char 类型的为单字节 不需要转换字节序
printf("name:%s\n",recvbuf.name);
printf("chinese:%d\n",ntohl(recvbuf.chinese));
printf("math:%d\n",ntohl(recvbuf.math));

}


close(sd);

exit(0);
}

接收方(被动方先运行),编译接收方源码,运行

1
2
$ make rever
$ ./rever

使用指令查看机器网络状态

1
$ netstat -anu   // netstat -aut 查看tcp通信情况

可以看到,0.0.0.0;1989在运行(地址与端口)

image-20240116222533582

发送方源码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
//
// 发送方
//
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>

#include "proto.h"


int main(int argc,char *argv[])
{
int sd;
struct msg_st sbuf; // 待发送的数据
struct sockaddr_in raddr; // 发送目标的地址

if(argc < 2)
{
fprintf(stderr,"USage...\n");
exit(1);
}

// 取得套接字 IPV4协议族 UDP套接字
sd = socket(AF_INET,SOCK_DGRAM,0);
if(sd < 0)
{
perror("scoket()");
exit(1);
}

// 将socket与地址绑定可以省略 因为UDP通信不需要进行连接,地址ip(本机ip)与端口(系统会分配一个空闲的给程序)
// bind()

// 定义需要发送的数据
strcpy(sbuf.name,"ZXZ"); // char为单字节 无需进行字节序转换
// 下面数据需要进行 从主机字节序向网络字节序的转换
sbuf.math = htonl(rand()%100);
sbuf.chinese = htonl(rand()%100);

// 初始化发送 目标(远端的)的地址
raddr.sin_family = AF_INET;
raddr.sin_port = htons(atoi(RCVPORT));
inet_pton(AF_INET,argv[1],&raddr.sin_addr); //地址用户写的是点分式 而程序需要的使用unint32_t的整型数,需要进行转换



// 发送信息至socket UDP使用 sendto函数
// 1.socket函数套接字描述符
// 2.指向包含要发送数据的缓冲区的指针
// 3.要发送的数据的字节长度
// 4.通常设置为0,但可以指定额外的标志来控制发送行为
// 5.指向包含目的地址(如对方的IP地址和端口)的struct sockaddr结构的指针
// 6.dest_addr指向的结构的大小
// 返回值小于0 则报错结束
if(sendto(sd,&sbuf, sizeof(sbuf),0,(void *)&raddr, sizeof(raddr)) < 0)
{
perror("sendto()");
exit(1);
}

puts("Send successful!");

close(sd);

exit(0);
}

编译运行

1
2
$ make snder
$ ./snder 127.0.0.1 // 127.0.0.1 本地ip 因为是在该实例是在一个机器内运行 本机运行
运行结果

可以发现接收方接收信息显示如下:

image-20240116222906568



4.多点通讯

多点通讯属于报式套接字,流式套接字是点对点的通信

(1)广播(全网广播\子网广播)
255.255.255.255

地址 255.255.255.255 在计算机网络中是一个特殊的广播地址。这个地址用于网络广播,它允许一台计算机发送信息给同一子网内的所有其他设备。在使用这个地址时,数据包会被发送到与发送者在同一本地网络(LAN)内的所有接收者

值得注意的是,这个地址仅限于本地网络使用,它不会被路由器转发到互联网上。这意味着,使用这个地址的信息只能在同一子网内的设备之间传播。在更广泛的网络应用中,这个地址并不用于公共互联网通信。

Socket options

在计算机网络中,Socket Options是用于修改套接字行为的一系列配置。套接字是网络通信中用于发送和接收数据的端点。在编程中,尤其是在TCP/IP(传输控制协议/互联网协议)栈的上下文中,可以设置各种套接字选项,以控制其行为和性能。这些选项包括但不限于:

  • O_REUSEADDR:允许重用本地地址和端口。这对于服务器快速重启非常有用,可以立即绑定到相同的地址和端口。
  • SO_KEEPALIVE:启用保活机制。如果在一定时间内没有数据交换,TCP将自动发送保活探测包。
  • SO_LINGER:控制套接字关闭的行为。它可以用来确保数据完全发送或设置套接字关闭前的超时时间。
  • SO_RCVBUFSO_SNDBUF:分别设置接收和发送缓冲区的大小。这对于控制数据传输的性能很重要。
  • SO_TIMEOUT:设置套接字操作的超时时间,如接收和发送数据。
  • TCP_NODELAY(针对TCP套接字):禁用Nagle算法来减少发送延迟。这对于需要低延迟的应用很有用,如实时游戏或音视频通信。
  • SO_BROADCAST:设置或获取广播标志。启用时,数据报套接字被允许发送数据包到一个广播地址。这个选项对面向流的套接字没有影响。(打开这个开关则可以实现广播,但是没有打开可能也可以接收到广播地址的数据包,这个属于未定义行为)
  • IP_MULTICAST_IF:是一个套接字选项(socket option),用于指定用于传出多播数据包的接口。在多播通信中,当你的设备有多个网络接口时,IP_MULTICAST_IF 允许你选择其中一个接口用于发送多播流量。这在服务器端编程中尤其有用,因为它确保多播数据包通过正确的网络接口发送。
setsockopt()

setsockopt() 函数用于设置套接字的选项,以控制其行为。这个函数是 Socket 编程接口的一部分,广泛用于网络通信程序中。setsockopt() 函数允许程序员设置各种套接字级别的选项,例如 TCP/IP 协议的参数、缓冲区大小、超时设置等

函数原型

1
2
#include <sys/socket.h>
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);

参数

  • sockfd:这是要设置选项的套接字的文件描述符。
  • level:指定选项所在的协议层。例如,要设置 TCP 协议的选项,可以使用 IPPROTO_TCP。对于通用的套接字选项,使用 SOL_SOCKET
  • optname:这是你想要设置的选项。例如,SO_REUSEADDRSO_RCVBUF 等。
  • optval:指向包含新选项值的缓冲区的指针(这个参数的确切类型和内容取决于参数3,用户想要设置的具体套接字选项,对于布尔类型的选项,通常设置为1表示启用或0表示禁用,如SO_BROADCAST,允许发送广播消息,就是bool类型的选项)
  • optlen:optval 缓冲区的大小。

返回值:

  • 成功时,setsockopt() 返回 0
  • 失败时返回 -1,并设置 errno 以指示错误
getsockopt()

getsockopt() 函数用于获取套接字的当前选项设置。这个函数是Socket编程接口的一部分,用于检索套接字选项的状态,如缓冲区大小、超时设置等

函数原型

1
2
#include <sys/socket.h>
int getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen);

参数

  • sockfd:这是要获取选项的套接字的文件描述符。
  • level:指定选项所在的协议层。例如,要获取 TCP 协议的选项,可以使用 IPPROTO_TCP。对于通用的套接字选项,使用 SOL_SOCKET
  • optname:这是你想要获取的选项。例如,SO_REUSEADDRSO_RCVBUF 等。
  • optval:指向一个缓冲区的指针,在这个缓冲区中,getsockopt() 会存放获取的选项值。
  • optlen:一开始是一个指向包含 optval 缓冲区大小的变量的指针。当 getsockopt() 返回时,这个变量被设置为实际获取的选项值的大小。

返回值

  • 成功时,getsockopt() 返回 0;
  • 失败时返回 -1,并设置 errno 以指示错误
if_nametoindex()

if_nametoindex 是一个在 Unix 和类 Unix 系统(比如 Linux)上常用的函数,它用于将网络接口名称(如 “eth0”、”wlan0” 等)转换为对应的接口索引号。这个接口索引号通常用于网络编程中,特别是在处理多播或原始套接字时。

函数原型

1
2
#include <net/if.h>
unsigned int if_nametoindex(const char *ifname);

参数:

  • ifname:网络接口名称

返回值:

  • 网络接口索引号
ip ro sh

ubuntu终端命令 ip ro ship route show 的缩写形式。这个命令用于显示系统的路由表信息。路由表是操作系统用来决定如何将数据包从一个网络传输到另一个网络的规则集。

具体来说,这个命令可以提供以下信息:

  • 目的网络:显示数据包的目的地网络和掩码。
  • 网关:数据包被发送到的下一个跳跃点(如果适用)。
  • 源地址:出站数据包的源地址(如果有特定路由策略)。
  • 使用的接口:用于发送数据包的网络接口,例如 eth0, wlan0 等。

举个例子,如果你在终端中运行 ip ro sh,可能会看到类似这样的输出:

1
2
default via 192.168.1.1 dev eth0 
192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.2

在这个例子中:

  • 第一行表示默认路由(default),所有不属于本地子网的流量都会通过这个路由发送。这里的默认网关是 192.168.1.1,通过 eth0 (以太网接口)接口。
  • 第二行描述了本地子网的路由。它显示了子网 192.168.1.0/24 是直接通过 eth0 接口可达的,本机的IP地址是 192.168.1.2

image-20240118223637419


(2)广播实例程序
通信协议

proto.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//
// Created by zxz on 2024/1/10.
//

#ifndef BASIC_PROTO_H
#define BASIC_PROTO_H

#define RCVPORT "1989" // 做实验尽量使用1024以上的端口,因为这些端口一般都是预留下来的

#define NAMESIZE 11

struct msg_st
{
uint8_t name[NAMESIZE];
uint32_t math;
uint32_t chinese;
}__attribute__((packed));
// __attribute__((packed)) 告知编译器 结构体不进行地址对齐

#endif //BASIC_PROTO_H
发送方

发送方需要向广播地址,进行发包,在发送方中需要将目标地址改为255.255.255.255网络广播地址,此外需要在程序中设置Socket Options,开启套接字广播允许标志

snder.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
//
// 发送方
//
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>

#include "proto.h"


int main(int argc,char *argv[])
{
int sd;
struct msg_st sbuf; // 待发送的数据
struct sockaddr_in raddr; // 发送目标的地址

// 取得套接字 IPV4协议族 UDP套接字
sd = socket(AF_INET,SOCK_DGRAM,0);
if(sd < 0)
{
perror("scoket()");
exit(1);
}


// 设置Socket属性
// 1.套接字文件描述符
// 2.选项所在的协议层,实例程序需要在套接字层面进行操作,即SOL_SOCKET
// 3.想要设置的选项,如允许发送广播信息:SO_BROADCAST
// 4.optval指向包含新选项值的缓冲区的指针
// 4.参数4(根据参数3,设置的选项填写参数4,对于布尔类型的选项,通常设置为1表示启用或0表示禁用)
// 5.optval缓冲区的大小
int val = 1;
if(setsockopt(sd,SOL_SOCKET,SO_BROADCAST,&(val),sizeof(val)) < 0)
{
perror("setsockopt()");
exit(1);
}



// 将socket与地址绑定可以省略 地址ip(本机ip)与端口(系统会分配一个空闲的给程序)
// bind()

// 定义需要发送的数据
strcpy(sbuf.name,"ZXZ"); // char为单字节 无需进行字节序转换
// 下面数据需要进行 从主机字节序向网络字节序的转换
sbuf.math = htonl(rand()%100);
sbuf.chinese = htonl(rand()%100);

// 初始化发送 目标(远端的)的地址---广播地址为 255.255.255.255
raddr.sin_family = AF_INET;
raddr.sin_port = htons(atoi(RCVPORT));
inet_pton(AF_INET,"255.255.255.255",&raddr.sin_addr); //地址用户写的是点分式 而程序需要的使用unint32_t的整型数,需要进行转换



// 发送信息至socket UDP使用 sendto函数
// 1.socket函数套接字描述符
// 2.指向包含要发送数据的缓冲区的指针
// 3.要发送的数据的字节长度
// 4.通常设置为0,但可以指定额外的标志来控制发送行为
// 5.指向包含目的地址(如对方的IP地址和端口)的struct sockaddr结构的指针
// 6.dest_addr指向的结构的大小
// 返回值小于0 则报错结束
if(sendto(sd,&sbuf, sizeof(sbuf),0,(void *)&raddr, sizeof(raddr)) < 0)
{
perror("sendto()");
exit(1);
}

puts("Send successful!");

close(sd);

exit(0);

}
接收方

接收方,需要在广播地址处,进行收包操作,需要在程序中对套接字属性进行设置,开启网络广播允许标志

rever.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
//
// 接收方
//
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

#include "proto.h"

#define IPSTRSIZE 1024

int main()
{
int sd;
struct msg_st recvbuf; // 用于存储接收的信息结构体
struct sockaddr_in laddr; // 定义一个用于ipv4协议的地址结构体 之后强转为struct sockaddr*,用于bind的第二个参数
struct sockaddr_in raddr; // 用于在recvfrom 函数中存储发送方的地址IP信息
socklen_t raddr_len;
char ipstr[IPSTRSIZE]; // 存储发送方点分式 IP
laddr.sin_family = AF_INET; // IPV4协议族
laddr.sin_port = htons(atoi(RCVPORT)); // 通信协议头文件指定的 端口号,并且字节序要进行转换,从主机字节序转换为网络字节序
// 使用 inet_addr()函数将点分十进制的IP地址字符串转换为合适的格式--无符号的整型数
// 地址可能变化,因此可是选择万能地址0.0.0.0
// laddr.sin_addr.s_addr = inet_addr("0.0.0.0"); // IPV4的地址 32位无符号的整型数---但是实际的IP地址是点分式如:192.168.1.1 因此需要进行使用inet_addr函数进行转换
// 替代上文该句代码
inet_pton(AF_INET,"0.0.0.0",&laddr.sin_addr);



// 取得socket
// 1.AF_INET 为ipv4网络协议族
// 2.SOCK_DGRAM 为报式套接字传输方式,通常用于UDP
// 3.不确定使用什么协议时,可以直接使用参数 0(即报式套接字的默认协议,IPPROTO_UDP)
// 4.返回一个套接字描述符
sd = socket(AF_INET,SOCK_DGRAM,0);
if(sd < 0)
{
perror("socket()");
exit(1);
}

// 设置Socket属性,启用广播标志,用于在广播地址收包
int val = 1;
if(setsockopt(sd,SOL_SOCKET,SO_BROADCAST,&val,sizeof(val)) < 0)
{
perror("setsockopt()");
exit(1);
}



// 将套接字与特定的IP地址和端口号绑定
// 1.函数调用返回的套接字文件描述符
// 2.是指向struct sockaddr结构的指针,该结构包含了要绑定的IP地址和端口号,参数2,可以使用(struct sockaddr *)进行转换但是(void *)是万能的
// 3.指定了addr结构的大小
if(bind(sd,(void *)&laddr,sizeof(laddr)) < 0)
{
perror("bind()");
exit(1);
}

/*!!!*/
/*特别重要需要对socklen_t raddr_len进行初始化,否则第一次通信时,会出错*/
raddr_len = sizeof(raddr);

while(1)
{
// 从Socket上接收信息 -- UDP使用 recvfrom
// 1.socket函数返回的套接字描述符
// 2.接收的信息存储的结构体,在通信协议中有定义
// 3.无特殊要求就填写0
// 4.用于存储发送方的地址IP信息结构体,根据具体协议定义(如:IPV4或者IPV6),最后需要进行强转为(struct sockaddr *)类型
// 5. 参数4存储发送方地址的结构体大小,类型为 socklen_t
recvfrom(sd,&recvbuf,sizeof(recvbuf),0,(void *)&raddr,&raddr_len);
// 打印发送方的地址与端口,端口号需要从网络字节序转换为主机字节序
// 地址需要从32号无符号整数转换为 点分式 192.168.0.0类型 使用函数 inet_ntop函数转换为点分式并且存储至 ipstr中
inet_ntop(AF_INET,&raddr.sin_addr,ipstr,IPSTRSIZE);
printf("---MESSAGE FROM %s:%d---\n",ipstr, ntohs(raddr.sin_port));
// 打印接收的结构体中的信息 name 是char 类型的为单字节 不需要转换字节序
printf("name:%s\n",recvbuf.name);
printf("chinese:%d\n",ntohl(recvbuf.chinese));
printf("math:%d\n",ntohl(recvbuf.math));

}

close(sd);

exit(0);
}
编译运行

终端对源码进行编译

1
2
$ make rever
$ make snder

打开一个终端,运行接收方

1
$ ./rever

打开一个终端,运行发送方

1
$ ./snder

接收方接收的消息,如图所是,发送方IP为198.168.1.106,

image-20240118225728309

终端使用命令ip ro sh,可以知道,本机ip为 192.168.1.106是子网192.168.1.0/24通过以太网接口enp5s0可达的(表示本机ip的数据包通过以太网接口eth0发送)

image-20240118230648220


(3)抓包软件wireshark使用

安装

1
$ sudo apt-get install wireshark

运行要需要root用户才可以,sudo之后输入用户密码即可

1
$ sudo wireshark

如图:

image-20240119125714305

wireshark使用实例

运行终端指令:ip ro sh,可以发现,本机ip192.168.1.106的数据包是从网关enp5s0发出的。

image-20240119131423617

运行./rever可执行文件,与./snder可执行文件

./rever方接收的信息为如下:

image-20240119131826082

信息从ip为192.168.1.106端口为57864发出,因此使用抓包软件,需要对enp5s0网卡的数据进行监测

打开wireshark软件,对网关enp5s0进行选择

image-20240119132056222

打开状态栏中的分析中的显示过滤器表达式,如下:

在过滤器表达式的左边字段名称中选择,IPV4 Internet Protocol Version 4下的ip.dst Destination Host

image-20240119132552338

在关系栏中选择,==关系,值中填写255.255.255.255。表示的含义是snder方发出的信息的目的ip是广播地址255.255.255.255

可以捕获的信息如下:

image-20240119132903513


(4)多播\组播

多播(Multicast)和组播是网络通信中的概念,用于描述在一个网络中从一个或多个源向多个目的地高效传送信息的过程。

多播

  • 定义:多播是一种网络传输机制,它允许单个数据源同时向多个接收者发送数据(而广播是所有可能的目标是有区别的)。这与单播(一个源到一个目标)和广播(一个源到所有可能的目标)相对。
  • 用途:多播主要用于节省带宽和提高效率,因为数据包只发送一次,但可以被网络中的多个接收者同时接收。这在流媒体、视频会议、实时应用程序等场景中非常有用。
  • 如何工作:在多播中,数据包被发送到一个特定的多播组地址。网络设备(如路由器)识别这个地址,并将数据包路由到订阅了该多播组的所有接收者。

组播

  • 定义:组播通常是指在特定的网络协议(如 IP 组播)中实现的多播。
  • IP 组播:在 IP 网络中,组播使用特定的地址范围(例如,IPv4 中的 224.0.0.0 到 239.255.255.255)。组播地址代表一个组,数据发送到这个地址将被分发给加入该组的所有成员。
224.224.224.224

224.224.224.224 是一个 IP 地址,属于 IP 组播(IP Multicast)地址范围。IP 组播是一种网络通信机制,用于有效地将数据包从一个源发送到多个目的地。

IP 组播地址范围

  • IP 组播地址被定义在特定的范围内。对于 IPv4,这个范围是 224.0.0.0239.255.255.255
  • 这个范围内的地址用于表示网络中的一个组播组,而不是特定的单个主机。

224.224.224.224 的特殊用途

  • 在实际中,224.224.224.224 这样的地址通常用于组播通信,比如视频流、多点会议系统,或者其他需要同时发送数据到多个接收者的应用。
  • 不过,这个特定的地址并没有被分配给任何 “众所周知的” 组播服务(像其他一些较低的组播地址那样)。这意味着它可能被用于特定应用程序或网络的自定义用途。

(5)多播\组播实例程序

流程:发送方,应该创建多播组,并且发送消息,而接收方则是加入发送方创建的的多播组,并且接收消息

1
2
3
4
5
6
7
8
struct ip_mreqn 
{
struct in_addr imr_multiaddr; /* IP multicast group
address */
struct in_addr imr_address; /* IP address of local
interface */
int imr_ifindex; /* interface index */
};
通信协议

在通信协议中定义,多播组号

proto.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//
// Created by zxz on 2024/1/10.
//

#ifndef BASIC_PROTO_H
#define BASIC_PROTO_H

#define MTROUP "224.2.2.2" // 多播组号
#define RCVPORT "1989" // 做实验尽量使用1024以上的端口,因为这些端口一般都是预留下来的

#define NAMESIZE 11

struct msg_st
{
uint8_t name[NAMESIZE];
uint32_t math;
uint32_t chinese;
}__attribute__((packed));
// __attribute__((packed)) 告知编译器 结构体不进行地址对齐

#endif //BASIC_PROTO_H
发送方

snder.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
//
// 发送方
//
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <net/if.h>


#include "proto.h"


int main(int argc,char *argv[])
{
int sd;
struct msg_st sbuf; // 待发送的数据
struct sockaddr_in raddr; // 发送目标的地址

// 取得套接字 IPV4协议族 UDP套接字
sd = socket(AF_INET,SOCK_DGRAM,0);
if(sd < 0)
{
perror("scoket()");
exit(1);
}


// 设置Socket属性---创建多播组
// 1.套接字文件描述符
// 2.选项所在的协议层,实例程序需要在IP层面进行操作,即IPPROTO_IP
// 3.想要设置的选项,如允许发送多播信息,创建多播组:IP_MULTICAST_IF
// 4.optval指向包含新选项值的缓冲区的指针
// 4.参数4(根据参数3,设置的选项填写参数4,根据参数3:IP_MULTICAST_IF,需要的参数4为 结构体 ip_mreqn 或者ip_mreq,与IP_ADD_MEMBERSHIP)
// 5.optval缓冲区的大小
struct ip_mreqn mreq;
inet_pton(AF_INET,MTROUP,&mreq.imr_multiaddr); // 将多播组地址转换为 转换为 mreq.imr_multiaddr需要的unint_32
inet_pton(AF_INET,"0.0.0.0",&mreq.imr_address); // 多播组的网络接口的 IP 地址 万能地址0.0.0.0
mreq.imr_ifindex = if_nametoindex("enp5s0"); // 网络接口(网卡)索引号,可以通过终端命令 ip ad sh 进行查看

if(setsockopt(sd,IPPROTO_IP,IP_MULTICAST_IF,&(mreq),sizeof(mreq)) < 0)
{
perror("setsockopt()");
exit(1);
}



// 将socket与地址绑定可以省略 因为UDP通信不需要进行连接,地址ip(本机ip)与端口(系统会分配一个空闲的给程序)
// bind()

// 定义需要发送的数据
strcpy(sbuf.name,"ZXZ"); // char为单字节 无需进行字节序转换
// 下面数据需要进行 从主机字节序向网络字节序的转换
sbuf.math = htonl(rand()%100);
sbuf.chinese = htonl(rand()%100);

// 初始化发送 目标(远端的)的地址---组播地址为 224.2.2.2
raddr.sin_family = AF_INET;
raddr.sin_port = htons(atoi(RCVPORT));
inet_pton(AF_INET,MTROUP,&raddr.sin_addr); //地址用户写的是点分式 而程序需要的使用unint32_t的整型数,需要进行转换



// 发送信息至socket UDP使用 sendto函数
// 1.socket函数套接字描述符
// 2.指向包含要发送数据的缓冲区的指针
// 3.要发送的数据的字节长度
// 4.通常设置为0,但可以指定额外的标志来控制发送行为
// 5.指向包含目的地址(如对方的IP地址和端口)的struct sockaddr结构的指针
// 6.dest_addr指向的结构的大小
// 返回值小于0 则报错结束
if(sendto(sd,&sbuf, sizeof(sbuf),0,(void *)&raddr, sizeof(raddr)) < 0)
{
perror("sendto()");
exit(1);
}

puts("Send successful!");

close(sd);

exit(0);
}
接收方

rever.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
//
// 接收方
//
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <net/if.h>

#include "proto.h"

#define IPSTRSIZE 1024

int main()
{
int sd;
struct msg_st recvbuf; // 用于存储接收的信息结构体
struct sockaddr_in laddr; // 定义一个用于ipv4协议的地址结构体 之后强转为struct sockaddr*,用于bind的第二个参数
struct sockaddr_in raddr; // 用于在recvfrom 函数中存储发送方的地址IP信息
socklen_t raddr_len;
char ipstr[IPSTRSIZE]; // 存储发送方点分式 IP
laddr.sin_family = AF_INET; // IPV4协议族
laddr.sin_port = htons(atoi(RCVPORT)); // 通信协议头文件指定的 端口号,并且字节序要进行转换,从主机字节序转换为网络字节序
// 使用 inet_addr()函数将点分十进制的IP地址字符串转换为合适的格式--无符号的整型数
// 地址可能变化,因此可是选择万能地址0.0.0.0
// laddr.sin_addr.s_addr = inet_addr("0.0.0.0"); // IPV4的地址 32位无符号的整型数---但是实际的IP地址是点分式如:192.168.1.1 因此需要进行使用inet_addr函数进行转换
// 替代上文该句代码
inet_pton(AF_INET,"0.0.0.0",&laddr.sin_addr);



// 取得socket
// 1.AF_INET 为ipv4网络协议族
// 2.SOCK_DGRAM 为报式套接字传输方式,通常用于UDP
// 3.不确定使用什么协议时,可以直接使用参数 0(即报式套接字的默认协议,IPPROTO_UDP)
// 4.返回一个套接字描述符
sd = socket(AF_INET,SOCK_DGRAM,0);
if(sd < 0)
{
perror("socket()");
exit(1);
}

// 设置Socket属性,加入多播组
struct ip_mreqn mreq;

inet_pton(AF_INET,MTROUP,&mreq.imr_multiaddr);
inet_pton(AF_INET,"0.0.0.0",&mreq.imr_address); // 网络接口ip,0.0.0.0使用默认的网路接口ip
mreq.imr_ifindex = if_nametoindex("enps50");
if(setsockopt(sd,IPPROTO_IP,IP_ADD_MEMBERSHIP,&mreq,sizeof(mreq)) < 0)
{
perror("setsockopt()");
exit(1);
}



// 将套接字与特定的IP地址和端口号绑定
// 1.函数调用返回的套接字文件描述符
// 2.是指向struct sockaddr结构的指针,该结构包含了要绑定的IP地址和端口号,参数2,可以使用(struct sockaddr *)进行转换但是(void *)是万能的
// 3.指定了addr结构的大小
if(bind(sd,(void *)&laddr,sizeof(laddr)) < 0)
{
perror("bind()");
exit(1);
}

/*!!!*/
/*特别重要需要对socklen_t raddr_len进行初始化,否则第一次通信时,会出错*/
raddr_len = sizeof(raddr);

while(1)
{
// 从Socket上接收信息 -- UDP使用 recvfrom
// 1.socket函数返回的套接字描述符
// 2.接收的信息存储的结构体,在通信协议中有定义
// 3.无特殊要求就填写0
// 4.用于存储发送方的地址IP信息结构体,根据具体协议定义(如:IPV4或者IPV6),最后需要进行强转为(struct sockaddr *)类型
// 5. 参数4存储发送方地址的结构体大小,类型为 socklen_t
recvfrom(sd,&recvbuf,sizeof(recvbuf),0,(void *)&raddr,&raddr_len);
// 打印发送方的地址与端口,端口号需要从网络字节序转换为主机字节序
// 地址需要从32号无符号整数转换为 点分式 192.168.0.0类型 使用函数 inet_ntop函数转换为点分式并且存储至 ipstr中
inet_ntop(AF_INET,&raddr.sin_addr,ipstr,IPSTRSIZE);
printf("---MESSAGE FROM %s:%d---\n",ipstr, ntohs(raddr.sin_port));
// 打印接收的结构体中的信息 name 是char 类型的为单字节 不需要转换字节序
printf("name:%s\n",recvbuf.name);
printf("chinese:%d\n",ntohl(recvbuf.chinese));
printf("math:%d\n",ntohl(recvbuf.math));

}


close(sd);

exit(0);
}
编译运行
1
$ make snder rever

分别打开终端运行接收方与发送方

image-20240119153503430



5.流式套接字(TCP)

收发双方的步骤

  • 被动端(S端先收包的一方,先运行):

    取得SOCKET

    给SOCKET取得地址

    将SOCKET设置为监听模式

    接受连接

    收/发消息

    关闭SOCKET

  • 主动端(C端):

    取得SOCKET

    给SOCKET取得地址(可省略)

    发送连接

    发/收消息

    关闭SOCKET

(1)流式套接字程序实例
通信协议

通信协议中需要确定发送端运行时候占用机器的端口号,以及服务端与客户端通信连接后,返回给客户端的信息格式FMT_STAMP

proto.h

1
2
3
4
5
6
7
8
9
10
11
12
//
// 通信协议
//

#ifndef BASIC_PROTO_H
#define BASIC_PROTO_H

#define SERVERPORT "1989"

#define FMT_STAMP "%lld\r\n"

#endif //BASIC_PROTO_H
接收方(Server第一版)

Server.c

第一版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
//
// 接收端
//

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

#include "proto.h"

#define IPSTRSIZE 40
#define BUFSIZE 1024


// 服务器接收连接后 需要告知客户端
static void server_job(int sd)
{
int len;
// 获取当前时间戳返回
char buf[BUFSIZE];
// 参数2:格式化字符串输出
len = sprintf(buf,FMT_STAMP,(long long)time(NULL));
// 收发信息
if(send(sd,buf,len,0) < 0)
{
perror("send()");
exit(1);
}
}

int main()
{
int sd;
int new_sd;
char ipstr[IPSTRSIZE];
struct sockaddr_in laddr; // 本地地址
struct sockaddr_in raddr; // 远端地址
socklen_t raddr_len;

// 取得Socket
sd = socket(AF_INET,SOCK_STREAM,0);
if(sd < 0)
{
perror("socket()");
exit(1);
}

laddr.sin_family = AF_INET;
laddr.sin_port = htons(atoi(SERVERPORT));
inet_pton(AF_INET,"0.0.0.0",&laddr.sin_addr);
// 为socket绑定好本端的ip与本端的端口号
if(bind(sd,(void *)&laddr,sizeof(laddr)) < 0)
{
perror("bind()");
exit(1);
}

// 将Socket 设置为监听模式
// 参数2:队列中最多可以容纳的的等待接收的连接请求数量
if(listen(sd,200) < 0)
{
perror("listen()");
exit(1);
}


raddr_len = sizeof(raddr);
while(1)
{
// 接受连接
// 参数2:用于接收一个返回值,表示连接到服务器的客户端的地址
new_sd = accept(sd,(void *)&raddr,&raddr_len);
if(new_sd < 0)
{
perror("accept()");
exit(1);
}

// 输出对端的ip与端口
// ip地址被转换为点分式则是字符型 单字节 不需要进行字节序的转换
inet_ntop(AF_INET,&raddr.sin_addr,ipstr,IPSTRSIZE);
printf("Client:%s:%d\n",ipstr, ntohs(raddr.sin_port));

server_job(new_sd);

// 关闭
close(new_sd);

}

close(sd);

exit(0);
}

编译运行server.c

打开一个终端运行:

1
$ ./server

打开另外一个终端,使用指令nc ,是ubuntu自带的客户端,指定服务器ip 与 端口号即可以与服务器进行通信

1
nc 0.0.0.0 1989

客户端端接收的信息如下,接收到服务端返回的时间戳

image-20240120163541389

运行服务端的终端,输出与其连接的客户端的ip与端口号

image-20240120163630925

使用Crtl+C,将服务端程序终止,再次运行会出现如下情况:

image-20240120163815424

显示服务端bind失败,需要使用的ip与端口已经为占用,这是因为,使用Ctrl+C终端程序运行,是使用信号中断进程的运行,在服务端程序中,并没有执行到close(sd),关闭套接字描述符的代码,因此在一小段时间内,该套接字还是会显示打开的状态,则ip与端口就会依然被占用。一段时间过后,系统发现后,则会主动进行资源的释放与回收。

如何改进呢?如下:

接收方(Server第二版)

设置套接字的属性,如套接字在上一次使用中没有来的及释放,那么在下一次重新使用bind绑定因为没有来得及释放而被占用的ip与端口时,则会立马释放,以此来使得此次bind成功

SO_REUSEADDR

  • SO_REUSEADDR 是一个套接字选项,用于在套接字编程中控制地址重用的行为。这个选项使得在同一端口上可以快速重启一个监听服务,而不必等待操作系统关闭前一个服务的套接字

在程序中添加代码:

1
2
3
4
5
6
7
// 设置套接字属性
int val = 1;
if(setsockopt(sd,SOL_SOCKET,SO_REUSEADDR,&val,sizeof(val)) < 0)
{
perror("setsockopt()");
exit(1);
}

server.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
//
// 接收接收端
//

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

#include "proto.h"

#define IPSTRSIZE 40
#define BUFSIZE 1024


// 服务器接收连接后 需要告知客户端
static void server_job(int sd)
{
int len;
// 获取当前时间戳返回
char buf[BUFSIZE];
// 参数2:格式化字符串输出
len = sprintf(buf,FMT_STAMP,(long long)time(NULL));
// 收发信息
if(send(sd,buf,len,0) < 0)
{
perror("send()");
exit(1);
}
}

int main()
{
int sd;
int new_sd;
char ipstr[IPSTRSIZE];
struct sockaddr_in laddr; // 本地地址
struct sockaddr_in raddr; // 远端地址
socklen_t raddr_len;

// 取得Socket
sd = socket(AF_INET,SOCK_STREAM,0);
if(sd < 0)
{
perror("socket()");
exit(1);
}

// 设置套接字属性
int val = 1;
if(setsockopt(sd,SOL_SOCKET,SO_REUSEADDR,&val,sizeof(val)) < 0)
{
perror("setsockopt()");
exit(1);
}

laddr.sin_family = AF_INET;
laddr.sin_port = htons(atoi(SERVERPORT));
inet_pton(AF_INET,"0.0.0.0",&laddr.sin_addr);
// 为socket绑定好本端的ip与本端的端口号
if(bind(sd,(void *)&laddr,sizeof(laddr)) < 0)
{
perror("bind()");
exit(1);
}

// 将Socket 设置为监听模式
// 参数2:队列中最多可以容纳的的等待接收的连接请求数量
if(listen(sd,200) < 0)
{
perror("listen()");
exit(1);
}


raddr_len = sizeof(raddr);
while(1)
{
// 接受连接
// 参数2:用于接收一个返回值,表示连接到服务器的客户端的地址
new_sd = accept(sd,(void *)&raddr,&raddr_len);
if(new_sd < 0)
{
perror("accept()");
exit(1);
}

// 输出对端的ip与端口
// ip地址被转换为点分式则是字符型 单字节 不需要进行字节序的转换
inet_ntop(AF_INET,&raddr.sin_addr,ipstr,IPSTRSIZE);
printf("Client:%s:%d\n",ipstr, ntohs(raddr.sin_port));

server_job(new_sd);

// 关闭
close(new_sd);

}

close(sd);

exit(0);
}

编译运行server.c

打开一个终端运行:

1
$ ./server

打开另外一个终端,使用指令nc ,是ubuntu自带的客户端,指定服务器ip 与 端口号即可以与服务器进行通信

1
nc 0.0.0.0 1989

客户端端接收的信息如下,接收到服务端返回的时间戳

运行服务端的终端,输出与其连接的客户端的ip与端口号

使用Crtl+C,将服务端程序终止,再次运行会出现如下情况:

image-20240120170023633

没有出现如第一版,bind失败的情形。

发送端1

Client.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
//
// 发送方
//
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <time.h>

#include "proto.h"

int main(int argc,char *argv[])
{
int sd;
struct sockaddr_in raddr; // 远端地址
long long stamp;
FILE *fp;

if(argc < 2)
{
fprintf(stderr,"Usage...\n");
exit(1);
}

// 获得socket
sd = socket(AF_INET,SOCK_STREAM,0);
if(sd < 0)
{
perror("socket()");
exit(1);
}

// 套接字绑定ip与端口---主动端可以省略
// bind();

raddr.sin_family = AF_INET;
raddr.sin_port = htons(atoi(SERVERPORT));
inet_pton(AF_INET,argv[1],&raddr.sin_addr);
// 发送连接
if(connect(sd,(void *)&raddr,sizeof(raddr)) < 0)
{
perror("'connect()");
exit(1);
}

// 收信息
// recv();

// 当此前建立连接成功之后,可以获得一个用于通信的套接字文件描述符
// 可以不使用recv函数接收信息,换个思路,将套接字文件描述符,转换为文件流的使用
// fdopen用于将现有文件描述符(file descriptor)转换为一个 FILE* 指针
fp = fdopen(sd,"r+w");
if(fp ==NULL)
{
perror("fdopen()");
exit(1);
}
// 从文件流中读取,服务端发送回来的信息,读取的信息存储至 stamp中
if(fscanf(fp,FMT_STAMP,&stamp) < 1)
{
fprintf(stderr,"Bad format!\n");
exit(1);
}
else
{
// 将取得信息输出至标准输出
fprintf(stdout,"stamp == %lld\n",stamp);
}

// 关闭
fclose(fp);

exit(0);

}
发送端2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/*
* 客户端
**/
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <time.h>

#include "proto.h"

#define BUFSIZE 1024

int main(int argc,char *argv[])
{
int sd;
struct sockaddr_in raddr;
long long stamp;
FILE *fp;
char BUF[BUFSIZE];


if(argc < 2)
{
fprintf(stderr,"Usage...\n");
exit(1);
}

// 获取socket
sd = socket(AF_INET,SOCK_STREAM,0);
if(sd < 0)
{
perror("socket()");
exit(1);
}

raddr.sin_family = AF_INET;
raddr.sin_port = htons(atoi(SERVERPORT));
inet_pton(AF_INET,argv[1],&raddr.sin_addr);
// 发送连接
if(connect(sd,(void *)&raddr,sizeof raddr) < 0)
{
perror("connect()");
exit(1);
}

// 接受服务端回传的数据
if(recv(sd,BUF,sizeof BUF,0) < 0)
{
perror("recv()");
exit(1);
}

// 将字符数组中的数据转化为长整型
stamp = strtoll(BUF,NULL,0);
fprintf(stderr,"stamp = %lld\n",stamp);

// 关闭
close(sd);
exit(0);
}

编译

1
$ make Client

先打开一个终端,运行服务端程序,等待客户端发送连接请求

1
$ ./server

再打开另外一个终端,运行客户端程序,发送连接请求,并且若连接成功则打印,服务端回传的信息

客户端显示:

image-20240120225041799

服务端显示:

image-20240120225059610

接收方(Server第三版并发版)

为了提高服务端的执行效率,服务端将接收连接以其之前的工作由父进程执行操作,而server_job等操作交由子进程进行操作

server.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
//
// 接收端
//

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

#include "proto.h"

#define IPSTRSIZE 40
#define BUFSIZE 1024


// 服务器接收连接后 需要告知客户端
static void server_job(int sd)
{
int len;
// 获取当前时间戳返回
char buf[BUFSIZE];
// 参数2:格式化字符串输出
len = sprintf(buf,FMT_STAMP,(long long)time(NULL));
// 收发信息
if(send(sd,buf,len,0) < 0)
{
perror("send()");
exit(1);
}
}

int main()
{
int sd;
int new_sd;
char ipstr[IPSTRSIZE];
pid_t pid;
struct sockaddr_in laddr; // 本地地址
struct sockaddr_in raddr; // 远端地址
socklen_t raddr_len;

// 取得Socket
sd = socket(AF_INET,SOCK_STREAM,0);
if(sd < 0)
{
perror("socket()");
exit(1);
}

// 设置套接字属性
// 尽快的释放绑定的ip与端口
int val = 1;
if(setsockopt(sd,SOL_SOCKET,SO_REUSEADDR,&val,sizeof(val)) < 0)
{
perror("setsockopt()");
exit(1);
}

laddr.sin_family = AF_INET;
laddr.sin_port = htons(atoi(SERVERPORT));
inet_pton(AF_INET,"0.0.0.0",&laddr.sin_addr);
// 为socket绑定好本端的ip与本端的端口号
if(bind(sd,(void *)&laddr,sizeof(laddr)) < 0)
{
perror("bind()");
exit(1);
}

// 将Socket 设置为监听模式
// 参数2:队列中最多可以容纳的的等待接收的连接请求数量
if(listen(sd,200) < 0)
{
perror("listen()");
exit(1);
}


raddr_len = sizeof(raddr);
while(1)
{
// 接受连接
// 参数2:用于接收一个返回值,表示连接到服务器的客户端的地址
new_sd = accept(sd,(void *)&raddr,&raddr_len);
if(new_sd < 0)
{
perror("accept()");
exit(1);
}

// 创建子进程
pid = fork();
if(pid <0)
{
perror("fork()'");
exit(1);
}
// 子进程的操作
// 容易出现的bug:创建子进程后,会将父进程的资源复制一份,因此子进程会将new_sd与sd都进行拷贝
// 但是每次使用完应该将不需要使用的套接字描述符进行关闭
if(pid == 0)
{
// 将子进程中的sd进行关闭,不需要在进行使用
close(sd);
// 输出对端的ip与端口
// ip地址被转换为点分式则是字符型 单字节 不需要进行字节序的转换
inet_ntop(AF_INET,&raddr.sin_addr,ipstr,IPSTRSIZE);
printf("[%d]:Client:%s:%d\n",getpid(),ipstr, ntohs(raddr.sin_port));

server_job(new_sd);

// 关闭
close(new_sd);
// 正常关闭子进程
exit(0);
}
else
{
// 父进程还需要进行的操作,将new_sd进行关闭
close(new_sd);
}

}

close(sd);

exit(0);
}

编译运行

1
$ make server

分别打开终端运行serverClient

发送方显示:

image-20240121111401992

接收方显示;

image-20240121111416922


(2)静态进程池套接字实现

在之前的流式套接字实例中,若在服务端,加入进程池的机制,当父进程建立连接之后,进程池中的子进程开始对任务进行竞争,那情况如何呢?

accept 调用通常是线程安全的,并且操作系统内核会负责处理多个并发的 accept 调用。这意味着多个线程可以安全地在同一个监听套接字上调用 accept 而不会相互干扰,因此accept函数不需要额外增加信号量或者进程锁相关

接收方(Server第四版静态进程池版本)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
//
// 接收端
//

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

#include "proto.h"

#define IPSTRSIZE 40
#define BUFSIZE 1024
#define PROCNUM 4

static void server_loop(int sd);
// 服务器接收连接后 需要告知客户端
static void server_job(int sd)
{
int len;
// 获取当前时间戳返回
char buf[BUFSIZE];
// 参数2:格式化字符串输出
len = sprintf(buf,FMT_STAMP,(long long)time(NULL));
// 收发信息
if(send(sd,buf,len,0) < 0)
{
perror("send()");
exit(1);
}
}

int main()
{
int sd,i;
int new_sd;
char ipstr[IPSTRSIZE];
struct sockaddr_in laddr; // 本地地址
pid_t pid;

// 取得Socket
sd = socket(AF_INET,SOCK_STREAM,0);
if(sd < 0)
{
perror("socket()");
exit(1);
}

// 设置套接字属性
// 尽快的释放绑定的ip与端口
int val = 1;
if(setsockopt(sd,SOL_SOCKET,SO_REUSEADDR,&val,sizeof(val)) < 0)
{
perror("setsockopt()");
exit(1);
}

laddr.sin_family = AF_INET;
laddr.sin_port = htons(atoi(SERVERPORT));
inet_pton(AF_INET,"0.0.0.0",&laddr.sin_addr);
// 为socket绑定好本端的ip与本端的端口号
if(bind(sd,(void *)&laddr,sizeof(laddr)) < 0)
{
perror("bind()");
exit(1);
}

// 将Socket 设置为监听模式
// 参数2:队列中最多可以容纳的的等待接收的连接请求数量
if(listen(sd,200) < 0)
{
perror("listen()");
exit(1);
}

for(i=0;i<PROCNUM;i++)
{
pid = fork();
if(pid < 0)
{
perror("fork()");
exit(1);
}
if(pid == 0)
{
server_loop(sd);
// 关闭子进程的sd
// 这些代码执行不到,因为在server_loop 函数中存在while循环,而子进程不会跳出while循环
close(sd);
exit(0);
}
}

// 子进程资源回收---代码也执行不到
for(i=0;i<PROCNUM;i++)
wait(NULL);

close(sd);
exit(0);

}


static void server_loop(int sd)
{
int new_sd;
struct sockaddr_in raddr; // 远端地址
socklen_t raddr_len;
char ipstr[IPSTRSIZE];

raddr_len = sizeof(raddr);
while(1)
{
// 接受连接
// 参数2:用于接收一个返回值,表示连接到服务器的客户端的地址
new_sd = accept(sd,(void *)&raddr,&raddr_len);
if(new_sd < 0)
{
perror("accept()");
exit(1);
}

// 输出对端的ip与端口
// ip地址被转换为点分式则是字符型 单字节 不需要进行字节序的转换
inet_ntop(AF_INET,&raddr.sin_addr,ipstr,IPSTRSIZE);
printf("[%d]--Client:%s:%d\n",getpid(),ipstr, ntohs(raddr.sin_port));

server_job(new_sd);

// 关闭
close(new_sd);

}
}

编译运行还是正常的


(3)套接字动态进程池实现

进程池中,父进程需要统筹管理进程池中的资源,当存在有子进程状态发生变化时,就要对进程池中的资源进行扫描,发现存在资源被空闲占用时,就需要将空闲的子进程杀死,释放其占用的资源。当发现存在此时进程池中子进程数量少于最小值时,需要创建子进程满足数量最小值,并执行任务

主进程每次间隔3s,就会扫描进程池,更新当前忙碌的子进程数量与空闲的子进程数量,当当前空闲的数量小于MINSPARESERVER,则会继续创建子进程使得存在子进程调用accept函数,等待客户端的连接。而不是每次发现有客户端发送连接请求了之后在去创建子进程。而且一开始就创建好空闲的子进程使用accept阻塞等待客户端连接请求

进程池还有一个思路:专门创建一个子进程用于accept函数阻塞等待客户端请求,当服务端连接上客户端,则创建其他子进程用于与客户端通信收发数据。

server.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
//
// 接收端
//
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <time.h>
#include <sys/wait.h>
#include <sys/mman.h>
#include <errno.h>


#include "proto.h"

#define MINSPARESERVER 5
#define MAXSPARESERVER 10
#define MAXCLIENTS 20

#define IPSTRSIZE 40
#define LINEBUFSIZE 2048

#define SIG_NOTIFY SIGUSR2 // SIGUSR2 是一个用户定义的信号

enum
{
STATE_IDEL = 0, // 空闲态
STATE_BUSY // 忙碌态
};

struct server_st
{
pid_t pid;
int state;
};

static struct server_st *serverpool;
static int idle_count=0,busy_count = 0;
static int sd;

// 这个函数不执行任何操作
static void usr2_handler(int s)
{
return;
}

/*子进程工作函数*/
static void server_job(int pos)
{
struct sockaddr_in raddr; // 远端地址
socklen_t raddr_len;
int ppid;
int client_sd;
time_t stamp;
int len;
char ipstr[IPSTRSIZE];
char linebuf[LINEBUFSIZE];
ppid = getppid(); // 获得当前进程的父进程ID
raddr_len = sizeof raddr;

while(1)
{
// 服务端子进程接受连接---返回一个可以用于与客户端通信的套接字文件描述符
client_sd = accept(sd,(void *)&raddr,&raddr_len);
if(client_sd <0)
{
// accept是系统调用
// 若不是假错,是真错误情况
if(errno != EINTR || errno != EAGAIN)
{
perror("accept()");
exit(1);
}
}
// 子进程将进行工作,将其状态设置为忙碌态
serverpool[pos].state = STATE_BUSY;
// 在子进程的工作函数中,子进程的状态发生了改变,因此需要通知主进程对进程池进行扫描与控制
kill(ppid,SIG_NOTIFY);
inet_ntop(AF_INET,&raddr.sin_addr,ipstr,IPSTRSIZE);
// 输出与服务端子进程连接的远端地址信息
// printf("[%d]client:%s:%d\n",getpid(),ipstr, ntohs(raddr.sin_port));

// 获得当前时刻的时间戳
stamp = time(NULL);
// 将获得的时间戳放到linebuf中
len = snprintf(linebuf,LINEBUFSIZE,FMT_STAMP,stamp);
// 向客户端回传数据,告知接受连接
send(client_sd,linebuf,len,0);
sleep(5);
close(client_sd);

// 服务端子进程工作结束,将其状态变为空闲态
serverPool[pos].state = STATE_IDLE;
// 为什么: 在子进程的工作函数中,子进程的状态发生了改变,因此需要通知主进程对进程池进行扫描与控制
// 将SIG_NOTIFY信号传递给父进程
// 这时候父进程在while循环使用sigsuspend(&oset)函数解除了信号SIG_NOTIFY的阻塞
// 父进程会调用usr2_handler在主进程的while循环中return一次,然后重新执行while循环
// 重新在while循环中,主进程对进程池进行扫描与控制
kill(ppid,SIG_NOTIFY);
}
}

/* 增加一个服务端子进程
* 当前只是创建一个服务端的子进程但是并没有实际使用创建的子进程进行工作
* 因此其状态为STATE_IDLE空闲态
* 在子进程中调用server_job函数才会正式开始工作,子进程状态才会变为忙碌状态
* */
static int add_1_server(void)
{
pid_t pid;
int slot;
if(idle_count + busy_count >= MAXCLIENTS)
// 出错情况--进程池满无法继续增加
return -1;
for(slot = 0;slot<MAXCLIENTS;slot++)
if(serverpool[slot].pid == -1)
// 当前slot位置的进程号是空闲的
break;
// 将当前创建的子进程状态先设置为空闲态
serverpool[slot].state = STATE_IDEL;
// 创建子进程
pid = fork();
if(pid < 0)
{
perror("fork()");
exit(1);
}

if(pid == 0) // 子进程
{
// 子进程工作
server_job(slot);
// 子进程退出
exit(1);
}
else // 父进程
{
serverpool[slot].pid = pid;
idle_count++;
}
return 0;
}

/*删除一个服务端子进程*/
static int del_1_server(void)
{
int i;
if(idle_count==0)
// 当前空闲状态的进程数量为0,则不用删除
return -1;
for(i=0;i<MAXCLIENTS;i++)
{
// 若当前进程池位置被占用,但是该进程为空闲态则删除
if(serverpool[i].pid != -1 && serverpool[i].state == STATE_IDEL)
{
// kill函数用于发送信号给进程
// SIGTERM信号 用于请求进程正常终止
kill(serverpool[i].pid,SIGTERM);
serverpool[i].pid=-1;
idle_count--;
break;
}
}
}


/*检查进程池*/
static int scan_pool()
{
int i;
int busy = 0,idle = 0;
for(i = 0;i<MAXCLIENTS;i++)
{
if(serverpool[i].pid == -1)
continue;
// 检测当前进程是否还存在
// 当 sig 为 0 时,kill() 实际上并不发送任何信号。它只是用于检测指定 pid 的进程或进程组是否存在,以及调用进程是否有权限向其发送信号
if(kill(serverpool[i].pid,0))
{
// 不存在则解除位置i的占用
serverpool[i].pid = -1;
continue;
}
if(serverpool[i].state == STATE_IDEL)
{
idle++;
}else if(serverpool[i].state == STATE_BUSY){
busy++;
} else{
// 输出错误
fprintf(stderr,"Unknown state\n");
// 中断该进程
abort();
}
}
idle_count = idle;
busy_count = busy;
return 0;
}



int main(int argc,char *argv[])
{
struct sockaddr_in laddr;
int i;

/*1.这个部分的处理是的主进程对于SIGCHLD信号的行为为忽视,并且不会考虑子进程终止时的主动收尸*/
struct sigaction sa,osa; // 定义信号的新行为与旧行为的结构体
// 功能:下面结构体sa初始化的功能是(特定的),当子进程的状态发生改变时,父进程对该信号SIGCHLD进行忽视
// 功能:并且,创建的子进程,结束时自行消亡,不等待父进程收尸
sa.sa_handler = SIG_IGN; // 对于信号SIGCHLD的反映行为是 (SIG_IGN忽视)
sigemptyset(&sa.sa_mask); // 处理该信号时额外需要被阻塞的信号集合,但是程序中将当前集合设置为空集
// SA_NOCLDWAIT 标志的主要作用是在子进程终止时,操作系统不保留子进程的状态信息,也不发送 SIGCHLD 信号给父进程。这种方式适用于那些不关心子进程状态的父进程,或者有其他机制来管理和监控子进程的程序。这样,父进程也就不需要调用 wait() 或 waitpid() 来清理子进程状态。
sa.sa_flags = SA_NOCLDWAIT; // 其他特殊要求,修改信号的行为标志,SA_NOCLDWAIT阻止子进程变为僵尸状态,免去收尸的困扰
// 用于修改信号SIGCHLD的行为
// 其中SIGCHLD信号,是一个由操作系统发送给父进程的信号,用来通知父进程其一个或多个子进程的状态发生了改变
// SIGCHLD 用于通知父进程其子进程的状态发生变化。可以用来处理子进程的终止、暂停、恢复等状态。
sigaction(SIGCHLD,&sa,&osa);
/***************************************************************************/


/*2.为用户自定义的信号SIG_NOTIFY修改行为*/
// struct sigaction sa,osa;
sa.sa_handler = usr2_handler; // 对于信号SIG_NOTIFY的反映行为是 usr2_handler一个处理函数
sigemptyset(&sa.sa_mask); // 处理该信号是额外需要被阻塞的信号集合,但是程序中将当前集合设置为空集
sa.sa_flags = 0; // 无其他特殊要求
sigaction(SIG_NOTIFY,&sa,&osa);
/***************************************************************************/

/*3.将SIG_NOTIFY设置为阻塞信号*/
sigset_t set,oset;
// 将set集合中的信号清空
sigemptyset(&set);
// 将信号SIG_NOTIFY 添加到set集合中
sigaddset(&set,SIG_NOTIFY);
// 将set 集合中的信号进行阻塞
// 执行下面的代码时,SIG_NOTIFY信号会被阻塞,这个信号不会传递到进程
sigprocmask(SIG_BLOCK,&set,&oset);
/***************************************************************************/

/*4.初始化进程池*/
// 使用匿名内存映射代替malloc为进程池开辟空间
serverpool = mmap(NULL,sizeof(struct server_st)*MAXCLIENTS,PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_SHARED, -1, 0);
// 创建失败
if(serverpool == MAP_FAILED)
{
perror("mmap()");
exit(1);
}
// 初始化进程池的信息
for(i = 0;i<MAXCLIENTS;i++)
{
serverpool[i].pid = -1;
}
/***************************************************************************/

/*5.获得套接字,并且绑定ip与端口,进行客户端连接请求的监听*/
// 创建套接字
sd = socket(AF_INET,SOCK_STREAM,0);
if(sd < 0)
{
perror("socket()");
exit(1);
}
// 设置套接字属性,使得套接字连接意外终止时,可以使得在同一端口上可以快速重启一个监听服务
int val = 1;
if(setsockopt(sd,SOL_SOCKET,SO_REUSEADDR,&val,sizeof(val)) < 0)
{
perror("setsockopt()");
exit(1);
}
// 为本地ip与端口进行绑定,被动端无法省略
laddr.sin_family = AF_INET;
laddr.sin_port = htons(atoi(SERVERPORT));
inet_pton(AF_INET,"0.0.0.0",&laddr.sin_addr); // 点分式 转unint_32整型
if(bind(sd,(void *)&laddr,sizeof(laddr)) < 0)
{
perror("bind()");
exit(1);
}
// 监听模式
if(listen(sd,100) < 0)
{
perror("listen()");
exit(1);
}
/***************************************************************************/

// 创建子进程---先创建最少数量的空闲服务端
for(i = 0;i<MINSPARESERVER;i++)
{
add_1_server();
}
printf("This ServerPool Running...\n");

while(1)
{
// 用于暂时替换进程的信号屏蔽字并暂停进程执行
// 调用sigsuspend使用程序开始时的信号集替换当前进程的信号屏蔽集,这个时候进程是可以接收到SIG_NOTIFY信号(未被屏蔽)的,则这个时候该进程可以受到信号SIG_NOTIFY的影响了
// 这样会使得进程进行阻塞等待的状态,直到接收到一个未屏蔽的信号。该信号将被传递给进程,并触发相应的信号处理程序或默认行为
// 在信号处理程序返回或默认行为执行完毕后,sigsuspend() 恢复进程的原始信号屏蔽集,即SIG_NOTIFY信号又变成了进程的阻塞信号
// 直到while循环下一次再次执行到sigsuspend该函数,往复执行
sigsuspend(&oset);

// 扫描进程池
scan_pool();

// control the pool
if(idle_count > MAXSPARESERVER)
{
// 若空闲的服务端子进程 多余最大的空闲允许值时,则杀死一部分服务端子进程
for(i = 0;i<(idle_count-MAXSPARESERVER);i++)
{
// 杀死一个服务端子进程
del_1_server();
}
}
else if(idle_count < MINSPARESERVER)
{
// 若空闲的服务端子进程 少余最小的空闲允许值时,则增加一部分服务端子进程
for(i = 0;i<(MINSPARESERVER-idle_count);i++)
{
// 增加一个服务端子进程
add_1_server();
}
}

// 在终端打印输出进程池状态
for(i = 0;i<MAXCLIENTS;i++)
{
// 进程池位置没有被占用,输出 s
if(serverpool[i].pid == -1)
putchar('s');
// 进程池位置被占用但是其状态为STATE_IDEL则输出 .
else if(serverpool[i].state == STATE_IDEL)
putchar('.');
// 进程池位置被占用但是其状态为STATE_BUSY则输出 x
else
putchar('x');
}
// 终端为行缓冲,在输出缓冲区遇到`\n`会将缓冲区内容输出至终端
putchar('\n');
}
/***************************************************************************/

// 进程恢复最初的信号屏蔽字的状态
// SIG_SETMASK:将当前信号屏蔽字设置为 oset 指定的值
sigprocmask(SIG_SETMASK,&oset,NULL);

// 释放内存映射区
munmap(serverPool, sizeof(struct server_st) * MAXCLIENTS);

// 主进程退出
exit(0);
}

编译运行启动server端

另开一个终端,运行

1
2
3
$ while true; do (./client 127.0.0.1 &);sleep 1;done   // 每秒运行一次指令 ./client
或者
$ while true; do (./client 0.0.0.0 &);sleep 1;done // 每秒运行一次指令 ./client

image-20240123160159720

服务端显示进程池中各个子进程的运行状态