进程协作与Socket
进程间协作
- UNIX的IPC(Inter-Process Communication,进程间通信)三样:
信号灯、共享内存、消息队列(SEM,SHM,MSG,目前用的少)
- 3种IPC机制的缺陷:没有贯彻“一切皆文件”的思路。
信号灯(semaphore)
控制多进程对共享资源的互斥性访问和进程间同步
策略和机制分离
-
UNIX仅提供信号灯机制,访问共享资源的进程自身必须正确使用才能保证正确的互斥和同步
-
不正确的使用,会导致信息访问的不安全(使用者可以不用PV操作直接访问资源)和死锁(信号灯的使用顺序问题),这些问题留给应用程序解决
P操作和V操作
信号灯机制实现了P操作和V操作,而且比简单PV操作功能更强
信号灯的使用
信号灯是OS内核中的一种对象,被多个进程共享,存在OS内核中。
// 信号灯的创建
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semget(int key, int nsems, int flags);
// 创建一个新的或获取一个已存在的信号灯组
// key:类似于名字
// nsems:该信号灯组中包含有多少个信号灯,例如生产者消费者需要3个信号灯
// flags:创建或者获取
// 函数返回一个整数,信号灯组的ID号(在操作系统内核中的某个数组当做下标使用);如果返回-1,表明调用失败
// 信号灯的删除
int semctl(int sem_id, int snum, int cmd, char *arg);
// 对信号灯的控制操作,如:删除,查询状态
// sem_id:信号灯组的ID号
// snum: 信号灯在信号灯组中的编号
// cmd: 控制命令,一般是宏
// arg: 执行这一控制命令所需要的参数存放区
// 返回值为-1,标志操作失败;否则,表示执行成功,
// 对返回值的解释依赖于cmd
// 删除系统中信号灯组的调用
semctl(sem_id, 0, IPC_RMID, 0);
// 信号灯的操作
int semop(int sem_id, struct sembuf *ops, int nops);
// 信号灯操作(可能会导致调用进程在此睡眠)
// sem_id:信号灯组的ID号
// ops: 有nops个元素的sembuf结构体数组,每个元素描述对某一信号灯操作
// 返回值:-1,标志操作失败;否则,表示执行成功
struct sembuf {
short sem_num; // 信号灯在信号灯组中的编号0,1,2…
short sem_op; // 信号灯操作,PV操作
short sem_flg; // 操作选项
};
// 当sem_op<0时,P操作;
// 当sem_op>0时,V操作;
// 当sem_op=0时,不修改信号灯的值,等待直到变为非负数。
// 原子性:一次semop()调用指定的多个信号灯的操作,Linux内核要么把多个操作一下全部做完,要么什么都不做
共享内存
特点
-
多个进程共同使用同一段物理内存空间
-
使用共享内存在多进程间传送数据,速度快,但进程必须自行解决对共享内存访问的互斥和同步问题(例如:使用信号灯通过P/V操作)
-
由于必须自行解决内存访问的同步互斥,因此较为麻烦,实际应用中也许管道更好用
应用举例
数据交换:多进程使用共享内存交换数据(最快的进程间通信方式),数据处理时追求高效率
运行监视:协议处理程序把有限状态机状态和统计信息放入共享内存中
- 协议处理程序运行过程中可随时启动监视程序,从共享内存中读取数据以窥视当前的状态,了解通信状况
- 监视程序的启动与终止不影响通信进程,而且这种机制不影响协议处理程序的效率
- 可以通过在进程页表中标注访问文件的模式(只读、只写)来保证安全性
共享内存的使用
int shmget(int key, int nbytes, int flags);
// 创建一个新的或获取一个已存在的共享内存段
// key:类似于名字
// nbytes:共享内存的字节数
// flags:类似于open的模式,创建或者获取
// 函数返回的整数是共享内存段的ID号;返回-1,表明调用失败
void *shmat(int shm_id, void *shmaddr, int shmflg);
// smh_id:共享内存段ID,要挂载的内存
// shmaddr:挂载到的地址,一般为0;逻辑地址由OS指定即可
// shmflg:挂载方式(只读、只写等)
// 获取指向共享内存段的指针(进程逻辑地址),返回-1:操作失败
int shmctl(int shm_id, int cmd, char *arg) ;
// 对共享内存段的控制操作,如:删除,查询状态
// smh_id:共享内存段ID
// cmd: 控制命令
// arg: 执行这一控制命令所需要的参数存放区
消息队列
略
生产者-消费者问题
问题
生产者/消费者用N个缓冲区构成的环形队列交换数据
程序设计
使用共享内存和信号量
- ctl create 创建共享内存段和信号灯
注意:进程创建的信号灯或者共享内存在OS内核中,进程死亡也不会消失,除非进程主动删除,或者OS重启
-
ctl remove 删除所创建的共享内存段和信号灯
-
producer 启动1个生产者进程(可以同时启动多个)
-
consumer 启动1个消费者进程(可以同时启动多个)
源程序文件
-
ctl.h 公用头文件
-
ctl.c 控制程序,创建/删除所需要的IPC机制
-
producer-consumer.c 生产者和消费者程序(二合一)
系统命令ipcs
显示IPC的状态
生产者-消费者问题实现
ctl.h
/* FILENAME: ctl.h */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <sys/shm.h>
#define N 8 /* 缓冲区个数 */
#define BUF_SIZE 128 /* 缓冲区大小 */
typedef struct { /* 缓冲区队列 */
int id;
int head, tail; /* 缓冲区队列的队首和队尾 */
char buf[N][BUF_SIZE]; /* 共N个缓冲区,每缓冲区128字节 */
} QUEUE;
/* 在"生产者-消费者"问题中共需要3个信号量,创建一个信号量组,含3个信号量 */
#define NUM_SEMAPHORE 3
#define MUTEX 0 /* 第0号信号量用于缓冲区队列的互斥访问 */
#define FULL 1 /* 第1号信号量用于记录已填满数据的缓冲区个数 */
#define EMPTY 2 /* 第2号信号量用于记录空闲缓冲区个数 */
#define SEM_KEY 0x11223344 /* 信号量组的KEY */
#define SHM_KEY 0x11223355 /* 共享内存段的KEY */
#define WHITE "\033[0;37m"
#define RED "\033[1;31m"
#define YELLOW "\033[1;33m"
#define GREEN "\033[1;32m"
#define PINK "\033[1;35m"
ctl.c
/* FILENAME: ctl.c */
#include "ctl.h"
static void create_ipc(void) /* 创建信号量组和共享内存段 */
{
int sem_id, shm_id;
struct sembuf ops[2];
QUEUE *q;
sem_id = semget(SEM_KEY, NUM_SEMAPHORE, IPC_CREAT | IPC_EXCL | 0666);
// 创建信号灯组
if (sem_id == -1) { /* 不能创建新信号量组时,给出错误信息 */
perror("Create Semaphores");
exit(1);
} else { /* 初始化信号量METEX和EMPTY */
printf("Create Semaphores: OK\n");
ops[0].sem_num = MUTEX;
ops[0].sem_op = 1; // V操作
ops[0].sem_flg = 0;
ops[1].sem_num = EMPTY;
ops[1].sem_op = N; // V操作
ops[1].sem_flg = 0;
if (semop(sem_id, ops, 2) == -1) {
perror("semop");
exit(1);
} else {
printf("Initialize Semaphores: OK\n");
}
}
shm_id = shmget(SHM_KEY, sizeof(QUEUE), IPC_CREAT | IPC_EXCL | 0666);
// 创建共享内存
if (shm_id == -1) { /* 创建共享内存段 */
perror("Create Share Memory");
exit(1);
} else {
printf("Create Share memory: OK\n");
q = (QUEUE *)shmat(shm_id, 0, 0); /* 获取指向共享内存段的指针 */
if (q == (QUEUE *)-1) {
perror("Attach Share Memory");
exit(1);
} else { /* 初始化缓冲区队列 */
q->head = q->tail = 0;
printf("Initialize QUEUE: OK\n");
}
}
}
static void remove_ipc(void) /* 删除信号量组和共享内存段 */
{
int sem_id, shm_id;
sem_id = semget(SEM_KEY, 0, 0); /* 根据KEY获取已存在的信号量组的ID */
// 获取信号灯组ID
if (sem_id == -1)
perror("Get Semaphores");
else {
if (semctl(sem_id, 0, IPC_RMID, 0) == -1) /* 删除信号量组 */
perror("Remove Semaphores");
else
printf("Remove Semaphores: OK\n");
}
shm_id = shmget(SHM_KEY, 0, 0); /* 根据KEY获取已存在的共享内存段的ID */
if (shm_id == -1)
perror("Get Share Memory");
else {
if(shmctl(shm_id, IPC_RMID, 0) == -1) /* 删除共享内存段 */
perror("Remove Share Memory");
else
printf("Remove Share Memory: OK\n");
}
}
int main(int argc,char **argv)
{
if (argc > 1 && strcmp(argv[1], "create") == 0)
create_ipc();
else if (argc > 1 && strcmp(argv[1],"remove") == 0)
remove_ipc();
else /* 打印该命令的使用方法 */
printf("Usage: %s { create | remove }\n", argv[0]);
}
producer-consumer.c
/* FILENAME: producer-consumer.c */
#include "ctl.h"
/* ------------------- IPC ---------------------------------*/
static int sem_id;
static QUEUE *q;
static void get_ipc(void)
{
int shm_id;
sem_id = semget(SEM_KEY, 0, 0); /* 获取已创建好的信号量组ID */
if (sem_id == -1) {
perror("Get Semaphore ID");
exit(1);
}
shm_id = shmget(SHM_KEY, 0, 0); /* 获取共享内存段的ID */
if (shm_id == -1) {
perror("Get Semaphore ID");
exit(1);
}
q = (QUEUE *)shmat(shm_id, 0, 0);/* 获取指向共享内存段的指针 */
if ((int)q == -1) {
perror("Attach Share Memory");
exit(1);
}
}
static void P(int sem_num) /* 对信号量组中的第sem_num号信号量执行P操作 */
{
struct sembuf op;
op.sem_num = sem_num;
op.sem_op = -1;
op.sem_flg = 0;
if (semop(sem_id, &op, 1) == -1) {
perror("P(semaphore)");
exit(1);
}
}
static void V(int sem_num) /* 对信号量组中的第sem_num号信号量执行V操作 */
{
struct sembuf op;
op.sem_num = sem_num;
op.sem_op = 1;
op.sem_flg = 0;
if (semop(sem_id, &op, 1) == -1) {
perror("V(semaphore)");
exit(1);
}
}
/* ------------------- PRODUCER ---------------------------------*/
static void produce(char *data)
{
int data_id;
/* 生产过程:将生产的数据放入进程私有缓冲区buf中,一个真正的生产过 */
/* 程可能需要较长时间,例如:一个复杂的数学计算,大型数据库检索,等等 */
sleep(1 + random() % 5);
P(MUTEX);
data_id = q->id++;
V(MUTEX);
sprintf(data, "PRODUCER-%d ID-%d", getpid(), data_id);
}
static void producer(void)
{
char data[BUF_SIZE]; /* 进程的私有缓冲区 */
for(;;) {
/* 生产过程:将新生产的数据放入进程的私有缓冲区buf中 */
produce(data);
P(EMPTY);
P(MUTEX);
/* 将已生产好的数据从进程的私有缓冲区buf,拷贝到缓冲队列的空闲缓冲区中 */
strcpy(q->buf[q->tail], data);
printf(PINK "Producer %d Write Buffer #%d, Data: %s"WHITE"\n",
getpid(), q->tail, q->buf[q->tail]);
q->tail = (q->tail + 1) % N;
V(MUTEX);
V(FULL);
}
}
/* ------------------- CONSUMER ---------------------------------*/
static void consume(char *data)
{
/* 消费过程:消费掉进程私有缓冲区buf中数据,一个真正的消费过程可能 */
/* 需要较长时间,例如:打印或通过低速网络向远程发送数据库查询结果,等等 */
sleep(1 + random() % 5);
}
static void consumer(void)
{
char data[BUF_SIZE]; /* 进程的私有缓冲区 */
for(;;) {
P(FULL);
P(MUTEX);
/* 将环行队列中缓冲区内容复制到进程私有缓冲区buf中 */
strcpy(data, q->buf[q->head]);
printf(GREEN "Consumer %d Read Buffer #%d, Data: %s"WHITE"\n",
getpid(), q->head, q->buf[q->head]);
q->head = (q->head + 1) % N;
V(MUTEX);
V(EMPTY);
consume(data); /* 消费过程:消费掉进程私有缓冲区buf中的数据 */
}
}
/* ------------------- MAIN ---------------------------------*/
int main(int argc,char **argv)
{
get_ipc();
if (strstr(argv[0], "consumer"))
consumer();
if (strstr(argv[0], "producer"))
producer();
}
执行效果
使用如下命令启动任意多个生产者或消费者
./consumer &
./producer &
内存映射文件
内存映射文件I/O
传统的访问磁盘文件的模式
打开一个文件,然后通过read和write访问文件
实际实现中需要进行一次内存拷贝,例如read,首先将文件内容从磁盘读入内存的OS内核的核心态内存,再拷贝到用户虚拟地址空间自己的内存中;write也一样。
实际可能更加复杂。write写10字节,可能会导致,首先把磁盘上的一块内容读入内存,修改10字节,然后再写回磁盘。
“内存映射”(Memory Map)方式读写文件
现代的Linux和Windows都提供了“内存映射”(Memory Map)方式读写文件的方法。
将文件中的一部分连续的区域映射成一段进程虚拟地址空间中的内存
- 进程获取这段映射内存的指针后,就把这个指针当作普通的数据指针一样引用。修改其中的数据,实际修改了文件;引用其中的数据值,就是读取了文件
- 访问文件跟内存中的数据访问一样。注意仍然需要事先用open()系统调用打开文件。open()负责把 i 结点之类调入内存,完成文件逻辑块和磁盘块之间的映射,另外完成对文件访问权限的判断处理。
- 系统不会为数据文件的内存映射区域分配相同大小的物理内存,而是由页面调度算法自动进行物理内存分配
- 根据虚拟内存的页面调度算法,按需调入数据文件中的内容,必要时淘汰(可能需要写入)内存页面
内存映射文件I/O的优点
-
比使用read,write方式速度更快
这两个系统调用的典型用法:
- len = read(fd, buf, nbyte);
- len = write(fd, buf, nbyte);
read需要内核将磁盘数据读入到内核缓冲区,再复制到用户进程的缓冲区中,write方法类似
内存映射方式是访问文件速度最快的方法,因为不需要内存拷贝。但是缺点也在于此,映射时要求映射到与某个页面对齐的位置上,映射有限制。
-
提供了多个独立启动的进程共享内存的一种手段
-
多个进程都通过指针映射同一个文件的相同区域,实际访问同一段内存区域,这段内存是同一文件区域的内存映射
-
某进程修改数据,就会导致另个进程可以访问到的数据发生变化,实现多进程共享内存的另外一种方式
-
在Windows下就可以通过这种方式实现多进程共享内存
-
注意:多进程之间访问时的同步和互斥,必须通过信号量等机制保证
-
相关系统调用
-
系统调用mmap
通知系统把哪个文件的哪个区域以何种方式映射
void *mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset);
执行成功,返回一个指针;否则返回-1,errno记录失败原因
-
mmap的参数
- addr指定逻辑地址空间中映射区的起始地址,一般选为0,让系统自动选择
- fd:已打开文件的文件描述符,先用open打开
- 映射的范围是从offset开始的len个字节
- prot对映射区的保护要求:PROT_READ和PROT_WRITE,必须与open打开匹配
- flags选MAP_SHARE,多进程共享方式
-
举例
char *p;
p = mmap(0, 65536, PROT_READ | PROTO_WRITE, MAP_SHARED, fd, 0);
- p是一个指针,是文件fd从0开始的65536个字节
- 程序像访问数组那样访问p[0]~p[65535]。操作这段内存最终读写磁盘文件。系统在合适时机将修改内容写回磁盘文件或者读取文件
-
系统调用munmap
程序调用函数munmap,或者,进程终止时,文件的内存映射区被删除
int munmap(void *addr, size_t len);
文件和记录的锁定
一个问题程序
/* 程序中省略了open等调用的错误处理 */
int fd, cnt;
fd = open("sanya",O_RDWR); // 文件中存一个4字节整数
for (;;) {
printf("按回车键售出一张票, 按Ctrl-D退出 . . .");
if (getchar() == EOF) break;
lseek(fd, SEEK_SET, 0); // 重新定位到文件头
read(fd, &cnt, sizeof(int));
if (cnt > 0) {
printf("飞往三亚机票, 票号:%d\n", cnt);
cnt--;
lseek(fd, SEEK_SET, 0); // 重新定位到文件头
write(fd, &cnt, sizeof(int));
} else
printf("无票\n");
}
close(fd);
问题:
多个进程执行该程序,操作同一个文件。多个进程同时运行时,由于中断的存在以及OS的进程调度,两个进程的指令可能是交叉执行的
![image-20200528112914472](https://raw.githubusercontent.com/StupidRabbit29/Img-Area/master/img/20200528120242.png)
文件和记录锁定机制
文件可以同时被多个进程访问,需要互斥
- (操作系统教科书中的“读者写者问题”)
使用信号灯机制和共享内存等方法
- 非常复杂,Linux提供了对文件和记录的锁定机制,用于多进程间对文件的互斥性访问
术语“记录”
- 指的是一个文件中从某一位置开始的连续字节流,Linux提供了对记录锁定的机制,用于锁定文件中的某一部分
- 可以把一个记录定义为从文件首开始直至文件尾,所以,文件锁定实际上是记录锁定的一种特例
共享锁和互斥锁
共享锁(或叫读锁)
- 多进程读操作可以同时进行,即某一进程读记录时,不排斥其它进程也读该记录,但是排斥任何对该记录的写操作
互斥锁(也叫写锁)
- 当某进程写记录时,排斥所有其它进程对该记录的读和写
锁操作(咨询式锁定)
什么是咨询式锁定:
例如给一个文件上读锁,此时有另外一个进程在写该文件,则申请锁的进程被阻塞,直到另外一个进程写完,然后获得读锁,重新回到执行状态。
执行解锁,可能导致其他进程从阻塞状态解除。
int fcntl(int fd, int cmd, struct flock *lock);
// fd 对哪个文件操作
// cmd 操作
// 基本应用:cmd在对记录上锁或解锁的应用中,一般取 F_SETLKW (等待)
// 其他应用:cmd取F_SETLK(不等待),加锁成功返回0,失败-1。
// 实现进程单例:对某数据文件指定字节上写锁可以实现系统中同一个程序只许启动一次进程的要求
// 结构体flock定义如下
struct flock {
short l_type; // F_RDLCK读锁;F_WRLCK写锁;F_UNLCK解锁
short l_whence; // 指定坐标0的位置:SEEK_SET文件首;SEEK_CUR当前读写指针处;SEEK_END文件尾
long l_start; // 指定记录的起点字节:可为正数或负数,相对于坐标0的偏移量,
long l_len; // 描述记录含有多少字节,值0指从指定位置开始超过文件尾,无穷大
};
实现进程单例
对某数据文件指定字节上写锁可以实现系统中同一个程序只许启动一次进程的要求
要求一个可执行文件只能执行一次,不能有两个同时执行。在系统中指定一个专门的文件,运行时,创建好文件,进程给该文件上不等待写锁。如果上锁成功,说明他是第一个进程。如果第二个进程开始执行,也尝试上锁,就会失败,然后可以打印信息,退出。如果第一个进程死亡,则锁消失,之后又可以启动一个进程。
样例
// 写锁
lock.l_whence = SEEK_SET;
lock.l_start = 0;
lock.l_len = sizeof(int);
lock.l_type = F_WRLCK;
fcntl(fd, F_SETLKW, &lock); // 上写锁,可能因为其他文件在写,而进入阻塞状态
lseek(fd, SEEK_SET, 0);
read(fd, &cnt, sizeof(int));
if (cnt > 0) {
printf("飞往三亚机票, 票号:%d\n", cnt);
cnt--;
lseek(fd, SEEK_SET, 0);
write(fd, &cnt, sizeof(int));
} else
printf("无票\n");
lock.l_type = F_UNLCK;
fcntl(fd, F_SETLK, &lock); // 解锁,可能导致其他进程从阻塞状态解除
// 读锁
lock.l_whence = SEEK_SET;
lock.l_start = 0;
lock.l_len = sizeof(int);
lock.l_type = F_RDLCK;
fcntl(fd, F_SETLKW, &lock); // 上读锁
lseek(fd, SEEK_SET, 0);
read(fd, &cnt, sizeof(int));
printf(" 还剩%d张票\n", cnt);
lock.l_type = F_UNLCK;
fcntl(fd, F_SETLK, &lock); // 解锁
咨询式锁定
进程严格规范的加锁,解锁,就可以保证没有冲突的访问;但是如果进程不管锁的存在,即别的进程用锁,他不用,那么该进程就可以无限制的访问,不受到锁的限制,会导致冲突。
咨询的意义就在于,这是一种无形的锁,询问一下有没有其他进程在使用,锁定了要访问的文件,然后决定是否进行自己的访问。如果不咨询别人,无形的锁就不会限制访问。
例如,cat 命令读一个上写锁的文件也不会受到限制,因为cat 命令,不会执行fcntl(),会直接read()。
Socket概述
Socket
协议栈实现
-
传输层以上由用户态应用程序实现(计算机网络提供给网络用户的最低层)
-
传输层(慢启动、拥塞控制等)和网络互联层(存储转发、路由协议)协议在内核中实现(软件实现)。(注意,路由协议由用户态进程实现,而不是内核态进程,可以修改内核中的路由表)
-
第一第二层一般由硬件实现(以太网、WiFi)
UNIX提供给应用程序使用网络功能的方法
-
BSD将设备和通信管道组织成文件方式,创建方式不同,访问方法相同(利用了UNIX本身的文件模型)
- 终端设备
- 管道
- 通信服务Socket
使用这种方式,编程人员对于新引入的网络服务(网络服务出现的比UNIX晚),可以使用原有对文件的操作来进行网络通信。
例如下面的程序中,无论 fd 引用的是磁盘文件、匿名管道、命名管道、终端设备文件、socket,程序都可以正常工作。
fd = atoi(argv[1]); while ((n == read(fd, buf, sizeof buf)) > 0){ ... } close(fd);
-
AT&T UNIX的TLI编程接口(传输层接口)
Socket编程接口面向各种网络通信,不仅仅用于TCP/IP
- 利用虚拟 loopback 接口(127.0.0.1),可实现同台计算机进程间通信,逐渐取代了消息队列等进程通信方式。
- 根据操作系统内核支持的协议栈,利用Socket的通用接口提供不同的服务。
- 类似虚函数,面向各种网络通信
TCP与UDP
TCP
-
面向连接
-
可靠(保证收到的数据是对的,但是不能保证一定可以收到数据,没有乱序、丢包、乱码)
-
字节流传输(类似管道)
- 不保证报文边界
UDP
-
面向数据报
-
不可靠
- 错报(一般不会出现,因为UDP下层的协议基本都有差错控制,有CRC校验,UDP自己也有校验和可供选择使用),丢报,乱序,没有流量控制(因此可能丢失数据)
-
数据报传输
-
支持广播和组播
网络字节顺序
CPU字节顺序
讨论的是一个多字节的数据结构(例如int)内部的存储顺序
-
Big Endian (大尾) 低地址存高字节
- Power PC,SPARC,Motorola
-
Little Endian (小尾) 低地址存低字节
- Intel X86
网络字节顺序——Big Endian
- 与X86相反,先发高位再发低位
网络字节转换的库函数
-
htonl ntohl 四字节整数(long)
-
htons ntohs 两字节整数(short)
Socket编程
Socket系统调用
socket
- 创建文件描述符socket,端点名未指定
bind
- 设定本地端点名,也可以用在客户端程序,客户端可以用来自己选择本地端点,否则系统connect时自动生成
listen
- 开始监听到达的连接请求
accept
- 接受一个连接请求,TCP三次握手结束accept返回
connect
- 建立连接,设定远端端点名,TCP连接建立,函数返回
close
- 关闭连接,释放文件描述符
简单TCP客户端-服务器程序
TCP客户端程序
-
创建文件描述符socket
-
建立连接connect
- 进程阻塞,等待TCP连接建立
-
端点名的概念:IP地址+端口号
-
本地端点名
-
远端端点名
-
发送数据
- 发送速率大于通信速率,进程会被阻塞
- 有发送缓冲区
-
关闭连接
// client.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h> // socket需要
#include <netinet/in.h> // inet的socket需要
#include <arpa/inet.h> // inet_ntoa,inet_aton等函数需要
#define SIZE 8192
#define PORT_NO 12345
int main(int argc, char *argv[])
{
int sock, len;
struct sockaddr_in name;
unsigned char sbuf[SIZE];
if (argc < 2) {
printf("Usage : %s <IP_ADDR>\n", argv[0]);
exit(0);
}
sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
// 创建socket,在OS内核里创建了socket对象,目前是空的
// 选择AF_INET协议栈,SOCK_STREAM流式的socket,选择TCP协议(6),如果参数为0,则默认使用TCP
if (sock < 0){
perror("Create stream socket");
exit(1);
}
// TCP需要指定对方的地址
name.sin_family = AF_INET; // 使用的协议
inet_aton(argv[1], &name.sin_addr); // 将命令行参数中的ASCII码IP地址转换成真实32位IP地址
name.sin_port = htons(PORT_NO); // 端口号
// 没有指定本地的端点名,connect会自动的查询,然后自动分配一个本地端点名
// 连接, TCP的三次握手
if (connect(sock, (struct sockaddr *)&name, sizeof(name)) < 0) {
// 连接失败
perror("\nconnecting server stream socket");
exit(1);
}
// connect本身会导致进程阻塞,在第二次握手时建立好了连接,即可继续执行
// 此后socket像文件一样可以读写
printf("Connected.\n");
for(;;) {
if (fgets(sbuf, sizeof sbuf, stdin) == NULL) break;
if (write(sock, sbuf, strlen(sbuf)) < 0) {
// write可能出错
perror("sending stream message");
exit(1);
}
}
// 像普通磁盘文件一样,使用后关闭
close(sock);
printf("Connection closed.\n\n");
exit(0);
}
TCP服务器程序
-
bind
-
设定本地端点名
-
也可以用在客户端程序
-
-
listen
- 进程不会在此被阻塞,仅仅给内核一个通知
-
accept
- 进程会在这里阻塞等待新连接到来
-
创建新进程时的文件描述符处理
-
问题:不能同时接纳多个连接——原因:read和accept都是阻塞的,不能同时执行,如果已经有一个socket连接,那么在read的时候,没法执行accept来处理别的连接
-
多进程并发处理
-
单进程并发处理
-
// server0.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define SIZE 8192
#define PORT_NO 12345
int main(void)
{
int admin_sock, data_sock;
struct sockaddr_in name;
unsigned char buf[SIZE];
int nbyte, i;
// 申请一个socket,但是不用来通信
admin_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
name.sin_family = AF_INET;
name.sin_addr.s_addr = INADDR_ANY; // 任何IP地址
name.sin_port = htons(PORT_NO);
// 指定socket的一个本地端点
bind(admin_sock, (struct sockaddr*)&name, sizeof(name));
// 监听状态,不进入阻塞状态,5代表最多可以缓存5个同时到来的连接
listen(admin_sock, 5);
// accept会导致阻塞,第3次握手后,accept返回;建立新的连接,返回一个新的socket
data_sock = accept(admin_sock, 0, 0);
printf("Accept connection\n");
for (;;) {
// data_sock 是新的 socket
nbyte = read(data_sock, buf, SIZE);
if (nbyte <= 0) {
printf("*** Disconnected.\n");
close(data_sock);
exit(0);
}
for (i = 0; i < nbyte; i++)
printf("%c", buf[i]);
}
}
为什么不能只用一个socket?
每一个TCP连接用一个socket,因为socket就像文件描述符,不能用一个socket写好多文件,或者用一个socket从多个文件中读。
相反,UDP中可以用一个socket做很多事。
执行效果
多进程并发处理
// server1.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/signal.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT_NO 12345
int main(void)
{
int admin_sock, data_sock, pid, name_len;
struct sockaddr_in name, peer;
admin_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (admin_sock < 0) {
perror("create stream socket");
exit(1);
}
name.sin_family = AF_INET;
name.sin_addr.s_addr = INADDR_ANY;
name.sin_port = htons(PORT_NO);
if (bind(admin_sock, (struct sockaddr*)&name, sizeof(name)) < 0) {
// 有可能绑定出错,例如端口已被绑定
perror("binding stream socket");
exit(1);
}
listen(admin_sock, 5);
signal(SIGCLD, SIG_IGN); /* 必须执行此操作,否则进程僵尸问题 */
for (;;) {
// 一定要赋值
name_len = sizeof(peer);
// 可以获取对方的端点名,&name_len是传入传出型的,传入peer的大小,返回实际写入的字节数
data_sock = accept(admin_sock, (struct sockaddr *)&peer, &name_len);
if (data_sock < 0) continue;
printf("Accept connection from %s:%d\n", inet_ntoa(peer.sin_addr), ntohs(peer.sin_port));
pid = fork();
if (pid == -1) { /* error */
perror("fork");
exit(1);
} else if (pid > 0) { /* parrent process */
close(data_sock); /* 必须执行此操作,原因有2 */
// fork后,父子进程各有一个data_sock
// 原因1 如果不关闭,父进程的文件描述符不断增加,之前的不关闭,文件描述符泄露
// 原因2 子进程关闭data_sock后,父进程没有关,那么TCP连接就不会断开
} else if (pid == 0) { /* child process */
char fd_str[16];
close(admin_sock);
sprintf(fd_str, "%d", data_sock);
execlp("./server1a", "./server1a", fd_str, NULL);
perror("execlp");
exit(1);
}
}
}
// server1a.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define SIZE 8192
int main(int argc, char *argv[])
{
int sock;
struct sockaddr_in peer, local;
socklen_t name_len;
unsigned char buf[SIZE];
int nbyte, i;
sock = strtol(argv[1], 0, 0); // 将命令行参数变为长整型
name_len = sizeof(peer);
getpeername(sock, (struct sockaddr *)&peer, &name_len);
getsockname(sock, (struct sockaddr *)&local, &name_len);
for (;;) {
nbyte = read(sock, buf, SIZE);
printf("%s:%d => ", inet_ntoa(peer.sin_addr), ntohs(peer.sin_port));
printf("%s:%d ", inet_ntoa(local.sin_addr), ntohs(local.sin_port));
if (nbyte < 0) {
perror("Receiving packet");
exit(1);
} else if (nbyte == 0) {
printf("*** Disconnected.\n");
close(sock);
exit(0);
}
for (i = 0; i < nbyte; i++)
printf("%c", buf[i]);
}
}
端点名相关的系统调用
getpeername 获取对方的端点名
getpeername(int sockfd, struct sockaddr *name, int *namelen);
getsockname 获取本地的端点名
getsockname(int sockfd, struct sockaddr *name, int *namelen);
注意:namelen是传入传出型参数,函数调用之前要先给整数namelen赋值,指出name缓冲区的可用字节数;函数返回时,namelen表示实际在name处写入了多少字节有效数据。虽然在TCP/IP中用处不大,但是socket作为通用的接口,这个参数很重要
read/write系统调用的语义
read/write与TCP通信的时序
主机A用write()通过TCP连接向B发送数据,B接收数据用read()
t0:B开始read(),进入阻塞状态
t1:A调用write(),TCP发送缓冲区有空闭,数据拷贝至发送缓冲区。write在t1返回,write的返回值并不能代表真的发送给B的字节数,仅仅是放入缓冲区的字节数
t2: A将数据发往B
t3: B收到数据后,校验和正确。read在t3返回
t4: B向主机A发ACK,ACK途中丢失
t5: A超时自动重发数据
t6: B收到重复的数据后扔掉,回送ACK
t7: A收到ACK,将发送缓冲区的数据清除
read/write与TCP通信故障和流控
-
流控问题,发的太快,或收的太慢
-
断线,网络故障或不发送,read会阻塞等待,write能立刻发现问题
-
对方重启动
-
Keepalive(默认两小时) ,有保活定时器。可以用来处理失效的连接,但是端到端通信,不能占用太多资源,不能频繁 Keepalive,故设定为2小时
-
getsockopt/setsockopt可以设置保活间隔,重传次数,重传时间
“粘连问题”
多次write,一次read;一次write,多次read。
read/write与UDP通信
-
网络故障
-
没有数据粘连
-
没有流控功能
-
不可靠,丢了也没有问题
read/write的其他版本
int recv(int sockfd, void *buf, int nbyte, int flags);
标志一般用0
int recvfrom(int sockfd, void *buf, int nbyte, int flags, struct sockaddr *from, int *fromlen);
int send(int sockfd, void *buf, int nbyte, int flags);
标志一般用0
int sendto(int sockfd, void *buf, int nbyte, int flags, struct sockaddr *to, int tolen);
recvfrom/sendto 可以指定对方的端点名,常用于UDP
Winsock只能用recv/send不可用read/write,因此,为了程序的兼容性,有人更愿意使用recv/send
shutdown系统调用
-
通用的调用,适用于各种协议
int shutdown(int sockfd, int howto);
禁止发送或接收。socket提供全双工通信,两个方向上都可以收发数据,shutdown提供了对于一个方向的通信控制
-
参数howto取值
SHUT_RD:不能再接收数据,随后read均返回0
SHUT_WR:不能再发送数据,本方向再次write会导致SIGPIPE信号
SHUT_RDWR:禁止这个sockfd上的任何收发
-
shutdown是通用的套接字上的操作
-
执行后对通信的影响,会与具体的通信协议相关
-
TCP协议
-
允许关闭发送方向的半个连接,发送是主动行为,不再发送,调用write会出错
-
没有一种机制让对方关闭它的发送,但TCP协议的流量控制机制,可以通知对方自己的接收窗口为0,对方的write会继续,并将数据堆积在发送缓冲区
-
-
UDP协议
-
UDP关闭接收方向内核仅记下一个标记,不再提供数据,但无法阻止对方的发送而导致的网络上数据
-
即使套接字关闭也不影响对方发出无人接收的数据报
-
socket控制
int getsockopt(int sokfd, int level, int optname, void *optval, int *optlen);
int setsockopt(int sockfd, int level, int optname, void *optval, int optlen);
int ioctl(int fd, int cmd, void *arg);
无阻塞I/O
socket、文件、管道都面临的问题:write、read都会导致进程阻塞,完成后返回
#include <sys/fcntl.h>
int flags;
flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NDELAY);
-
发送缓冲区满,write立即以-1返回,errno置为EWOULDBLOCK (或EAGAIN)
-
发送缓冲区半满,write返回实际发送的字节数
-
接收缓冲区空,read立即以-1返回,errno置为EWOULDBLOCK
不要滥用,使用不当会产生忙等待问题。
单进程并发处理
面向事件的编程模式,相比于多进程编程更复杂,但是进程执行的效率更高
select
多路I/O
引入select系统调用的原因
-
使得用户进程可同时等待多个事件发生
-
用户进程告知内核多个事件,某一个或多个事件发生时select返回,否则,进程睡眠等待。避免忙等待。
int select(int maxfdp1, fd_set *rfds, fd_set *wfds, fd_set *efds, struct timeval *timeout);
-
参数说明:最大文件描述符+1(后面3个集合中的最大文件描述符+1,与select实现方法有关:比特图),读集合,写集合,异常情况集合,超时时间结构体(毫秒,微妙级,指定为空说明无限等待)
-
例如:告知内核在rfds集合{4,5,7}中的任何文件描述符“读准备好”,或在wfds集合{3,7}中的任何文件描述符“写准备好”,或在efds集合{4,5,8}中的任何文件描述符有“异常情况”发生
-
集合参数是传入传出型,select返回后会被修改,只有准备好的文件描述符,仍出现在集合中
-
集合参数允许传NULL,表示不关心这方面事件
“准备好”
什么叫“准备好”
-
rfds 中某文件描述符的read不会阻塞(连接断开,也不会阻塞,立即返回-1)
-
wfds 中某文件描述符的write不会阻塞
-
efds 中某文件描述符发生了异常情况
-
TCP协议,只有加急数据到达才算“异常情况”
-
对方连接关闭或网络故障,不算“异常情况”,都是正常情况
-
“准备好”后可以进行的操作
-
当“读准备好”时,调用read会立刻返回-1/0/字节数(连接断开,对方关闭连接,正常read)
-
当“写准备好”时,调用write可以写多少字节?
-
> =1个字节
-
“无阻塞I/O”方式。原因:如果使用阻塞式的write,可能会导致因为写过多内容,导致进程阻塞,无法继续让select关注多个事件。
-
集合操作
这些函数在windows和linux中的实现是不一样的。
预定义数据类型 fd_set (在C语言头文件定义)
void FD_ZERO(fd_set *fds);
将fds清零:将集合fds设置为“空集”
void FD_SET(int fd, fd_set *fds);
向集合fds中加入一个元素fd
void FD_CLR(int fd, fd_set *fds);
从集合fds中删除一个元素fd
int FD_ISSET(int fd, fd_set *fds);
判断元素fd是否在集合fds内
超时
select 的最后一个参数timeout
struct timeval {
long tv_sec; /*秒*/
long tv_usec; /*微秒*/
};
-
定时值不为0:select在某一个描述符I/O就绪时立即返回;否则等待但不超过timeout规定的时限
- 尽管timeout可指定微秒级精度的时间段,依赖于硬件和软件的设定,实际实现一般是10毫秒级别
-
定时值为0:select立即返回(无阻塞方式查询)
-
空指针NULL:select等待到至少有一个文件描述符准备好后才返回,否则无限期地等下去,注意,使用0代表空指针,和定义一个
timeval
结构体,里面的时间赋值为0,是不同的。
代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define SIZE 8192
#define PORT_NO 12345
#define syserr(prompt) { perror(prompt); exit(1); } // 宏,失败后打印信息并终止
static int receive_data(int sock)
{
unsigned char rbuf[SIZE];
struct sockaddr_in peer;
int i, nbyte;
socklen_t name_len = sizeof(peer);
nbyte = recv(sock, rbuf, SIZE, 0);
if (nbyte < 0) {
perror("receiving stream packet");
return 0;
}
getpeername(sock, (struct sockaddr *)&peer, &name_len);
printf("%s:%d ", inet_ntoa(peer.sin_addr), ntohs(peer.sin_port));
if (nbyte == 0) {
printf("*** Disconnected.\n");
return 0;
}
for (i = 0; i < nbyte; i++)
printf("%c", rbuf[i]);
return 1;
}
int main(void)
{
int admin_sock, data_sock, ret, n, fd;
struct sockaddr_in name;
fd_set fds, rfds;
admin_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (admin_sock < 0)
syserr("Create socket");
name.sin_family = AF_INET;
name.sin_addr.s_addr = INADDR_ANY;
name.sin_port = htons(PORT_NO);
if (bind(admin_sock, (struct sockaddr*)&name, sizeof(name)) < 0)
syserr("Binding socket");
// 最大文件描述符+1
listen(admin_sock, 5);
printf("ready\n");
n = admin_sock + 1;
FD_ZERO(&fds);
FD_SET(admin_sock, &fds);
for(;;) {
// select 返回后会改变描述符集合,需要重新赋值
memcpy(&rfds, &fds, sizeof(fds));
// 无线等待的select
ret = select(n, &rfds, 0, 0, 0);
if (ret < 0)
syserr("select");
if (ret == 0)
continue;
if (FD_ISSET(admin_sock, &rfds)) {
// admin_sock 改变,说明有新连接建立
data_sock = accept(admin_sock, 0, 0);
if (data_sock < 0)
syserr("accept");
FD_SET(data_sock, &fds);
if (n <= data_sock)
n = data_sock + 1;
}
for (fd = 0; fd < n; fd++) {
// TCP 连接的数据socket
if (fd != admin_sock && FD_ISSET(fd, &rfds)) {
ret = receive_data(fd);
if (ret == 0) {
close(fd);
FD_CLR(fd, &fds);
}
}
}
}
}
执行效果
libevent
开源跨平台轻量级事件管理库
-
C语言编写的、轻量级的开源高性能事件通知库
-
事件驱动(event-driven),类似于select,高性能
-
跨平台(Windows/Linux/BSD/MacOS),在OS之上封装,易于移植
-
支持多种I/O多路复用技术:select(在UNIX中管理socket、管道、终端、文件等),epoll,kqueue等
-
支持I/O,定时器和信号等事件
-
可作为一些应用的底层的网络库
UDP通信
客户端
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define SIZE 8192
#define PORT_NO 12345
int main(int argc, char *argv[])
{
int sock, len;
struct sockaddr_in name;
unsigned char buf[SIZE];
if (argc <2 ) {
printf("Usage : %s <IP_ADDR>\n",argv[0]);
exit(0);
}
// 创建socket
sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (sock < 0){
perror("create socket");
exit(1);
}
name.sin_family = AF_INET;
inet_aton(argv[1], &name.sin_addr);
name.sin_port = htons(PORT_NO);
if (connect(sock, (struct sockaddr *)&name, sizeof(name)) < 0) {
// 不会握手,只是让OS将对方的端点记录在socket中,以便后续读写。源地址会由系统自动分配
perror("\nconnect");
exit(1);
}
for(;;) {
if (fgets(buf, sizeof buf, stdin) == NULL)
break;
len = write(sock, buf, strlen(buf));
if (len < 0) {
perror("sending message");
break;
}
printf("send %d bytes\n", len);
}
close(sock);
}
connect
-
不产生网络流量,内核记下远端端点名
-
之前未用bind指定本地端点名,系统自动分配本地端点名
write
-
使用前面的connect调用指定的端点名
-
UDP不是面向连接的协议,可在sendto参数中指定对方端点名,而且允许对方端点名不同
-
每次都使用sendto发送数据,前面的connect调用没必要了
-
connect/第一次sendto 可使得socket获得系统动态分配的本地端点名,未获得本地端点名之前不该执行read或recv以及recvfrom
服务器端
// udpserver.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define SIZE 8192
#define PORT_NO 12345
int main(void)
{
int sock, len;
struct sockaddr_in name;
unsigned char buf[SIZE];
sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (sock < 0) {
perror("create socket");
exit(1);
}
name.sin_family = AF_INET;
name.sin_addr.s_addr = INADDR_ANY;
name.sin_port = htons(PORT_NO);
if (bind(sock, (struct sockaddr*)&name, sizeof(name)) < 0) {
perror("binding socket");
exit(1);
}
for (;;) {
len = read(sock, buf, sizeof buf);
if (len < 0) {
perror("read");
exit(0);
}
printf("Receive %d bytes\n", len);
}
}
// udpserver2.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define SIZE 8192
#define PORT_NO 12345
int main(void)
{
int sock, len;
struct sockaddr_in name, peer;
socklen_t addr_len;
unsigned char buf[SIZE];
sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (sock < 0) {
perror("create socket");
exit(1);
}
name.sin_family = AF_INET;
name.sin_addr.s_addr = INADDR_ANY;
name.sin_port = htons(PORT_NO);
if (bind(sock, (struct sockaddr*)&name, sizeof(name)) < 0) {
perror("binding socket");
exit(1);
}
for (;;) {
addr_len = sizeof(peer);
len = recvfrom(sock, buf, sizeof buf, 0, (struct sockaddr *)&peer, &addr_len);
// 可以获取对方的端点信息,知道信息来源,可以进一步处理和返回信息。
if (len < 0) {
perror("recvfrom");
continue;
}
printf("Received %d bytes from %s:%d\n", len, inet_ntoa(peer.sin_addr), ntohs(peer.sin_port));
sendto(sock, buf, len, 0, (struct sockaddr *)&peer, sizeof peer);
}
}
UDP通信
接收
-
没有数据到达时,read调用会使得进程睡眠等待
-
一般需区分数据来自何处,常用recvfrom获得对方的端点名
发送
-
服务器端发送数据常用sendto,指定远端端点名
-
对接收来的数据作应答,sendto引用的对方端点名利用recvfrom返回得到的端点名
select定时
-
select可实现同时等待两个事件:收到数据和定时器超时
-
用time(0)(以秒为单位的时间坐标)或者gettimeofday()(以毫秒为单位的时间坐标)获得时间坐标,计算时间间隔决定是否执行超时后的动作
死锁问题
- 举例:发送方发送数据,数据中途丢失,但是发送方不知情,仍在等待返回信息,此时就进入了死锁状态。