网络聊天室(基于多进程TCP)
题目描述:
模拟一个在线聊天室 连接时需要告诉服务器用户名,支持多用户同时登录
两种聊天功能:
一:(群聊)服务器转发:用户->服务器->其他所有用户
二:(私聊)点对点,不通过服务器
要求:
1.基于TCP协议
2.server端要用多进程实现
3.在linux下用C语言实现
需求分析
- 要实现题目中的模拟聊天室,服务器需要用多进程的方式,每个进程专门为一个客户服务,客户端也需要使用多进程的方式(收发消息独立实现)。 在服务器的主进程内再开一个线程用于分发消息,即服务器收到的群聊消息都要通过它来下发,因为主进程拥有全部的打开的socket,这其中需要涉及到进程间通信(IPC)。
- 为了完成私聊的功能,客户端需要内嵌一个用于私聊的server和client模块。私聊时,由服务器定向转发一条私聊服务消息,内含私聊对方的IP和PORT,然后私聊双方建立新的TCP连接即可跳过服务器实现私聊。
算法设计
客户端:
流程图:
main函数
进行:创建socket、connect。
然后获取验证信息,正式通信前的验证
然后进入while(1)循环,循环内区分出主进程和子进程
- 主进程用来发消息,子进程用来收消息,两个进程相互独立,并且两个进程都是阻塞的(内部是while1),子进程读取到用户输入的‘0’表示退出,退出的实现:首先给服务器发‘0’告知自己退出了,然后调用exit()退出。父进程读取到子进程的SIGCHLD信号以后调用wait()防止有僵尸进程,然后调用exit()自己也退出。
- 主进程内部逻辑比较简单,一直接受通过socket发来的信息显示到屏幕上并做出错处理。 其中:收到’7’表示用户已经接受此程序的私聊申请(之前一定发出了申请)
然后就开始接收来自服务器的连续两条消息,分别是私聊目标的ip和port,成功接收这两条消息之后就调用Client子程序连接到目标服务器,在此之前先调用kill函数给子进程发信号kill(pid,SIGUSR1);
,让其捕捉到之后退出exit,为的是避免多个进程scanf出错。
子进程(发消息进程):
一个死循环,循环内读取键盘输入的内容到buf,判断buf的内容,如果是‘0’表示要退出,上面已经描述。如果是‘1’表示要查询当前聊天室成员,直接发送给服务器服务器会作出判断并发回查询结果。如果是‘2’表示要发起私聊,程序会提示输入要私聊的对象,然后输入待连接的用户名,发送给服务器,请求结果服务器会自动发回来(对方拒绝或者接受),不需要多做处理。如果是‘=’表示这是一条响应外部私聊请求的指令(私聊请求直接以一条信息的形式发来,用户是被动的接受 的,但是可以选择是否连接)输入‘=’表示响应此连接,会发送回执的ACK消息为‘=’到服务器,服务器作出判断,然后进行连接。这里发送‘=’之后会提示输入自己的IP和Port,系统读取后发回服务器,服务器会将信息转发给发起私聊者,然后其根据此信息连接到本机器以进行私聊。
两个子程序
内部实现比较简单,基于单连接的TCP,内部有两个进程,一个用于私聊发消息,另一个收消息。
void Server(int MyPort){}
void Client(int MyPort){}
//参数是私聊连接时的PORT
- 这里要注意:调用
Server
或Client
私聊子程序后,应防止子程序与原进程冲突。
这里的方法,主进程调用server之前给子进程用kill发信号SIGUSR1,子进程接收到后就执行exit()而退出。子进程调用client之前给父进程用kill发信号SIGUSR2,父进程收到后就执行wait()而阻塞,通过这种方法保证任何时刻只有一个进程执行IO操作,避免冲突。
服务器端:
程序大部分的功能都集中在服务器端。
流程图:
准备工作:
对当前聊天室内的所有客户,系统需要记录他们的信息,而且需要在所有客户(进程)之间共享,所以设计统一的数据结构保存一个客户的信息(name、socket、pid),并且用共享内存的方式将所有用户的信息存在一起,方便访问,共享内存的第一块数据结构存的是整体的信息(互斥锁的状态、当前总用户数)。
main函数:
- 创建socket、bind、listen、accept。
- 主程序包含while(1)在listen之后,accept()包含在其中。
每次accept之后都创建一个个子进程,子进程是阻塞的(一直处理某个客户的请求直至结束),主进程不断接受新的连接并将其分配给新的进程,由于先创建的进程有较少的打开的socket而主进程拥有所有的socket,所以首次进入时,在主进程内部创建一个线程(也是阻塞式的),某个进程要给不是自己直接客户发送消息的时候就需要通过此进程做转发,这里利用管道来实现,每次转发需要先写一个标志位用以表示需要哪种转发的方式(群发还是发给个人)。主进程还有另外一个线程用来做管理(包括:服务器主动查询用户信息,服务器主动发消息等)
pthread_t thread;
pthread_create(&thread, NULL, hand_out, NULL); //独立的线程做转发
pthread_t thread1;
pthread_create(&thread1, NULL, manage, NULL); //独立的线程做管理
- 服务器一般是不停机的,所以这里没有给出退出的方式。
- 收到新用户连接:上面已经说了:新的连接是分配给新的子进程去做的。在新的子进程内部调用child_handle函数,在该函数内部处理,该函数退出后就关闭对应的socket以及exit()。
child_handle()函数:
原型
void child_handle(int new_sock){}
//参数是服务对象的socket
- 先做正式通信前的验证(用户名),验证成功就调用Loggin函数表明其已经登陆了。然后服务器会给客户发送一个标志位’1’,用户接收到之后在客户端就会显示出菜单,表示可以开始聊天了,随即客户端和服务器都进入while(1)循环。
服务器在循环内,会不断用recv接收来自客户端的消息,每收到一条信息都要对其判断: - 如果是‘0’表示:客户端即将退出,这里直接return即可(退出child_handle后会调用exit使进程消亡,主进程会捕捉到SIGCHILD信号)
signal(SIGCHLD, wait_sig);
- 如果是‘1’表示:收到用户查询当前聊天室所有用户的请求,直接访问共享内存读取当前用户信息,然后发送反馈回客户端即可。
- 如果是‘2’表示:用户要发出私聊,然后就收取一条包含用户名的消息。随后根据此用户名查询共享内存中的用户信息,如果不存在此用户则发回客户端错误提示消息并continue。如果确实存在此用户则保存他的socket信息为target_sock,并发送提示信息"存在此用户,正在尝试连接",然后就需要给target_sock发消息询问,这里要借用主进程的专门负责发消息的线程来实现,这里通过管道来实现,这里连续向管道写入三条信息: 标志位"#"表示私聊服务、target_sock表示私聊对象、最后还有一个包含发起私聊用户名的询问字符串,对于私聊child_handle()函数就只处理到这里了。
- 最后如果以上标志位都不满足,表示这是一条普通的群聊消息,直接做转发即可,这里也需要借用主进程的专门负责发消息的线程来实现,这里连续向管道写两条消息:标志位"9"表示转发群聊消息服务、包含发送消息者用户名的群聊消息。然后就交给专门用来发送信息的线程hand_out()来处理了。
Loggin函数:
参数:用户名+socket
此函数先打开共享内存(互斥访问)然后在当前存在的用户序列的尾部插入一个新用户,修改其信息,用户总数加一。实现互斥的机制:这里的用户信息存储结构是
struct C_user
{ //储存用户信息的结构
char name[20];
int sock_lock;
int pid_num;
int double_chat_lock;
};
然后在共享内存内部开一定大小的空间以struct C_user数组的形式储存信息。
在头部有一个头节点不存用户信息,存的是系统信息。
其name为Head,pid_num表示当前聊天室内用户总数,sock_lock用来做互斥锁,值为1表示开锁可以访问,值为0表示关锁不能访问。
处理SIGCHILD信号的函数wait_sig
原型
void wait_sig(int a)
//参数是发出此信号的进程的PID
- 此函数是用来处理子进程退出的,主要是在函数内部wait()回收资源,并执行和登录相反的操作,即注销,删除其在用户信息队列内的消息,具体实现方法是遍历存在共享内存中的用户信息数组,根据PID找到退出的进程后,依次用后一个信息覆盖前一个,并修改头节点head内的信息(总数-1),注意这里需要用到互斥锁。
发送信息的线程hand_out():
- 进入处理函数hand_out()后就是一个while(1)循环
不断读取管道内的信息(标志位) - 如果读取到‘9’表示要分发群聊消息,首先再次读管道,读到的内容即为待发送的消息,然后访问共享存储区内的用户信息,然后遍历整个列表给每位用户都发送一遍消息即可。
- 如果读到标志位’#'表示这是私聊请求的转发,再连续读取三次管道分别得到发送目标target_sock和发出请求的用户new_sock和待发送的消息msg,然后就将msg通过target_sock发送到目标用户即可。
然后就等待用户发送反馈消息回来,如果收到‘=’表示目标接受了这次私聊请求,如果接收到其他内容表示拒绝。
目标接受私聊之后服务器通知私聊发起者目标接受了私聊。然后就从目标target_sock再接收两条信息分别是IP 和 Port。服务器接收到这两个信息之后直接转发给发起者以便其发起连接。 - 注意以上所有发送和接收都需要做出错处理。
遇到的问题:
socket 编程 Transport endpoint is not connected 错误:
- 在 server 端 accept 一个新的连接后返回一个文件描述符
(这里就叫 connect_fd),但这个文件描述符有区别于初始化 socket 的 listen_fd 文件描述符。
在 read 函数的时候错误将 listen_fd 传入到函数中,最终导致 Transport endpoint is not connected 错误。
一台机器多个程序的多进程间共享内存问题
- 客户端刚开始的设计沿用了服务器端共享内存的方式实现互斥,但是在调试过程中发现一个问题,即不同的客户端内的进程之间有相互影响。这是因为在不同程序内部开共享内存,但用同样的键值,而在测试的时候多客户端是开在同一台物理主机下的,考虑到实际应用时也有可能发生在本地同时登录两个账户的情况存在,于是这里放弃了共享内存的方式实现互斥。
socket发送小数据不及时
- 调试过程中发现利用socket发送的数据有时明明发出去了,但在接收端没有接收到,经过大量测试发现每次发生这种情况都是在发送大小比较小的数据时发生,于是猜测是socket内部有一定大小的发送缓冲区,缓冲区满了以后统一用TCP打包发送出去,而我要发送的小数据达不到缓冲区的最小标准,于是就等待缓冲区被接下来的包占满才发。这里解决的方法也比较简单粗暴:直接在出问题的发送后面随机再发送一个空的大的数据包,这样就能保证小数据及时被接收了。
互斥访问
程序里面涉及到以下几个地方需要互斥的访问/利用:
-
1.共享存储器:
这是在服务器端储存当前用户信息的区域,每个进程在访问时都要互斥的访问以免发生与时间和运行次序有关的错误,这里利用了共享存储器的头节点里面空余的一个数据域sock_lock
做互斥,这个量正常情况下是表示某个用户的socket但在头节点内表示互斥锁的取值取0表示被锁住取(不能访问)1表示开锁(可以访问)。但在服务器的管理模式下忽略了这个互斥锁。 -
recv:在服务器内的服务进程一直做recv,虽然有多个进程同时recv但是来源的socket不同,这不会产生冲突,但是在私聊转发的时候,负责转发的线程(属于主进程)也需要从socket读取数据,这就与服务进程产生了冲突,这里只需要利用上面的互斥锁将其阻塞,转发服务结束后再打开即可。
-
scanf:在客户端原本是由一个专门的进程负责收(这里需要用到scanf)另一个专门的进程负责发,但是在私聊建立成功后,成功建立私聊的那个进程既需要收又需要发,这就产生了多个进程同时从屏幕scanf的问题,这里的解决方法是:挂起原先的进程,通过发送kill信号,并在其他进程内响应kill信号(exit或者wait),就能保证同一时刻只有一个进程在scanf。