实现基于Linux网络编程+多线程编程的简易网络聊天室

前言

众所周知,网络聊天室的应用已经融入我们生活的方方面面,微信、QQ、飞书等等,这篇文章介绍如果利用套接字编程(网络编程)+多线程编程实现一个简易的网络聊天室。

前置知识

网络编程

套接字网络编程属于OSI模型的传输层,传输层有2个协议,TCP和UDP,这里说说TCP和UDP的区别。

  1. 连接性:
    • TCP是一种面向连接的协议。在发送数据之前,TCP需要在通信的两端建立一个连接。这个连接是可靠的,确保数据的顺序和完整性。
    • UDP是一种无连接的协议。每个数据包(datagram)都是独立的,发送方不需要与接收方建立连接。因此,UDP的传输速度通常比TCP更快,但是不保证数据的到达顺序或完整性。
  2. 可靠性:
    • TCP提供可靠的数据传输。它使用确认机制和重传策略来确保数据的完整性和顺序性。如果一个数据包丢失或损坏,TCP会重新发送该数据包。
    • UDP不提供可靠的数据传输。它不包含确认机制或重传策略,因此数据包可能会丢失或乱序,接收方需要自行处理丢失的数据或重新排序数据包。
  3. 数据流:
    • TCP是面向流的协议。它将数据视为连续的字节流,并使用序号来标识每个字节的位置。TCP会将数据分割成合适的大小,并保证数据按照正确的顺序到达接收方。
    • UDP是面向数据报的协议。它将数据划分为数据报(datagram),每个数据报都是独立的,没有顺序性的要求。
  4. 头部开销:
    • TCP的头部开销相对较大,因为它包含了用于连接管理、流量控制和错误恢复的额外信息。
    • UDP的头部开销较小,因为它不包含连接状态或其他控制信息。
  5. 适用场景:
    • TCP通常用于对数据传输可靠性要求较高的场景,如文件传输、电子邮件、网页浏览等。
    • UDP通常用于实时应用或对传输延迟要求较高的场景,如音频/视频流媒体、在线游戏、VoIP等。

下面列出网络编程的几个常用函数:

socket 函数

说明

socket 函数是用来创建网络通信的一个接口,它是网络编程中的基础。这个函数主要用于生成一个新的socket,即网络通信的端点。在UNIX和类UNIX系统中,socket被看作是一种特殊的文件,其操作方式与文件操作类似。

原型

int socket(int domain, int type, int protocol);
  • domain:指定socket通信的协议族。常见的协议族包括
    • AF_INET:IPv4网络协议族。
    • AF_INET6:IPv6网络协议族。
    • AF_UNIX:本地通道,使用在同一台机器上的客户端和服务器之间的通信。
  • type:指定socket的类型,决定了通信的特性。常见的类型有
    • SOCK_STREAM:提供面向连接的稳定数据传输,即TCP协议。
    • SOCK_DGRAM:使用不连续不可靠的数据包连接,即UDP协议。
    • SOCK_RAW:提供原始网络协议访问。
  • protocol:指定在给定的域和类型下,使用的具体协议。通常情况下,如果只有一种选择,该值可以设为0,表示使用默认协议。

返回值

  • 成功:返回一个新的socket描述符,用于后续的所有操作(如连接、数据传输等)。
  • 失败:返回-1,并设置errno以指示错误类型。

示例代码

2fb4aee2c3db5cde0bba90309dd634a06deeb37ef185933221297bffcece5998

注意事项

  • 使用完socket后,应调用close()函数关闭socket,释放资源。
  • 在进行网络编程时,应考虑网络字节序和主机字节序之间的转换,特别是在处理IP地址和端口号时。
  • 错误处理是网络编程中非常重要的一个部分,确保对socket函数的调用后检查返回值,并适当处理错误情况。
bind 函数

说明

用于将一个本地地址绑定到指定的socket上。

这个步骤是在创建socket之后,进行数据传输之前的重要环节,尤其是对于服务器端的应用程序来说,因为它允许服务器指定一个固定的端口进行监听。

原型

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfd:是由之前调用 socket 函数成功返回的socket描述符。

  • addr:是一个指向 struct sockaddr 结构体的指针,该结构体中包含了要绑定的地址和端口信息。对于IPv4,这个结构体通常是通过 struct sockaddr_in 来具体实现的,然后通过类型转换为 struct sockaddr 来传递给 bind 函数。

    • struct sockaddr{  unisgned short as_family;  char sa_data[14]; };
      
    • struct sockaddr_in {
                 sa_family_t     sin_family;     /* AF_INET */
                 in_port_t       sin_port;       /* Port number */
                 struct in_addr  sin_addr;       /* IPv4 address */
      };
      
  • addrlen:指定了地址结构的长度,对于IPv4是 sizeof(struct sockaddr_in),对于IPv6是 sizeof(struct sockaddr_in6)

返回值

  • 成功:返回0。
  • 失败:返回-1,并设置 errno 来标识错误。

示例代码

0bb8b2b3cfbce57abf7e71871735e7642b5119179b6f23193ee69113ba34af68

注意事项

  • 在调用 bind 之前,确保地址结构正确无误。
  • 对于使用UDP协议的socket,如果没有绑定地址和端口,则系统会在发送数据时随机分配一个端口。
  • 如果端口已经被占用,bind 调用会失败,errno 会设置为 EADDRINUSE
  • 确保使用 htonshtonl 函数来转换端口号和IP地址,以符合网络字节序。
listen 函数

说明

用于使一个已经绑定到地址的socket进入被动监听状态,这一点对于服务器端应用尤为关键。被动监听意味着socket准备好接受来自客户端的连接请求。

原型

int listen(int sockfd, int backlog);
  • sockfd:是一个已经通过 socket 创建且通过 bind 绑定了地址的socket描述符。
  • backlog:指定在socket上未处理连接的最大数量。具体来说,这是内核应该为相应socket排队的最大传入连接数。如果有更多的连接请求,系统可能会开始拒绝额外的请求,具体取决于操作系统的处理方式。

返回值

  • 成功:返回0。
  • 失败:返回-1,并设置 errno 以表示错误。

示例代码

e81ccdc557643965f4e9075b4ec71ca55f7a2bea58bac50998bde8c9853528d0

注意事项

  • backlog 的具体大小取决于应用需求和系统限制,通常应该足够大,以容纳预期的客户端连接数。
  • 使用 listen 后,socket只是准备好接受连接,并不实际接受连接。实际的连接接受由 accept 函数完成。
  • 在多数现代操作系统中,如果 backlog 设置得过大,系统可能会自动减少其值到一个合适的最大值。
  • 在多客户端的网络应用中,通常结合 select, poll, 或 epoll 等函数来有效地管理多个连接。
accept 函数

说明

用于从处于监听状态的socket队列中接受一个连接请求,创建一个新的socket,并返回一个新的文件描述符来处理这个连接。这个函数是在服务器端应用中处理客户端连接请求的关键步骤。

默认情况下会阻塞,直到有一个传入的连接。

原型

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • sockfd:是已经绑定到一个本地地址并处于监听状态的socket描述符,通常是之前通过 listen 函数设置的socket。
  • addr:(可选)一个指针,指向一个 struct sockaddr 结构,该结构用于接收远端(即连接的客户端)的网络地址信息。
  • addrlen:(可选)一个指向 socklen_t 变量的指针,这个变量在调用前应该被设置为传入结构的大小,调用后这个变量将被设置为实际接收到的地址的长度。

返回值

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

示例代码

9b5b738dbbf361a4441326acf800b28a3c79ebdbdc86da0ba75cbac17eae4599

注意事项

  • 如果 addr 和 addrlen 不是 NULL,那么连接客户端的地址信息会被填充到 addr 指向的结构体中,并将实际地址的长度写入 addrlen 指向的变量。
  • 在多线程或多进程的服务器程序中,通常在 accept 后立即用另一个线程或进程来处理新的连接,以便主监听循环可以继续接受其他连接请求。
  • accept 是一个阻塞调用,服务器将在这里停顿,直到一个客户端连接请求到达。为了处理多个客户端或非阻塞行为,可以使用 select, poll, 或 epoll 等机制。
connect 函数

说明

用于建立与指定网络地址的服务器之间的连接。这个函数主要用在客户端,它启动与服务器的TCP三次握手过程。当你在客户端使用TCP协议(如SOCK_STREAM)创建一个socket后,使用 connect 函数可以尝试与服务器建立连接。

默认情况下会阻塞,直到连接成功建立或发生错误。

原型

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfd:是由 socket 函数返回的socket文件描述符。
  • addr:指向一个 struct sockaddr 结构体,该结构体包含了目标服务器的地址和端口信息。对于IPv4,这个结构体通常是通过 struct sockaddr_in 来具体实现的,然后通过类型转换为 struct sockaddr 来传递给 connect 函数。
  • addrlen:指定了传入地址结构的长度。对于IPv4是 sizeof(struct sockaddr_in),对于IPv6是 sizeof(struct sockaddr_in6)

返回值

  • 成功:返回0,表示连接已成功建立。
  • 失败:返回-1,并设置 errno 以指示错误。

示例代码

64b3962eb256da14416c15ca701c8c1225c4f9a0105ab821208a3443010b80b2

注意事项

  • connect 在连接建立前是阻塞的,即它会等待直到连接成功或发生错误。对于非阻塞socket,connect 可能立即返回 -1,此时 errno 设置为 EINPROGRESS,表示连接建立正在进行。
  • 如果 connect 调用失败,应检查 errno 以确定失败的原因,如 ECONNREFUSED 表示远程地址没有在监听请求的端口,或者 ETIMEDOUT 表示连接尝试超时。
  • 使用IPv6地址时,应使用 struct sockaddr_in6 结构,并确保地址和端口的正确设置。
send 函数

说明

用于通过已连接的socket发送数据。它常用于客户端和服务器应用程序中,用来向对方发送数据。

默认情况下会阻塞,直到所有数据都被发送(或部分数据被发送,具体取决于协议和缓冲区状态)。

原型

ssize_t send(int sockfd, const void *buf, size_t len, int flags);
  • sockfd:是指向已经通过 connect(客户端)或 accept(服务器端)建立的连接的socket描述符。
  • buf:是指向包含待发送数据的缓冲区的指针。
  • len:是要发送的数据的字节长度。
  • flags:提供额外的信息如何发送数据,通常为0。其他选项包括 MSG_DONTROUTE, MSG_DONTWAIT, 和 MSG_NOSIGNAL 等。

返回值

  • 成功:返回实际发送的字节数。这个值可以小于请求发送的字节数,表示只发送了部分数据。
  • 失败:返回-1,并设置 errno 以指示错误。

示例代码

c6ef13d8d58b946af53a80ea2bb1006d4fa13edf71b9ac8b67979806c93cfceb

注意事项

  • 如果 send 返回值小于预期发送的长度,应用程序可能需要重新发送剩余的数据。
  • 一些错误条件(如连接中断)可能导致 send 失败并返回 -1。对于非阻塞socket,如果数据不能立即发送,send 也可能返回 EAGAIN 或 EWOULDBLOCK。
  • 在多线程环境中发送数据时,确保同一socket的发送操作不会由多个线程同时执行,除非通过适当的同步机制保证线程安全。
recv 函数

说明

用于从一个已经建立连接的 socket 接收数据。它是 TCP 连接中用于接收从远程发送来的数据的主要函数之一,通常用于服务器端和客户端的应用程序中。

默认情况下会阻塞,直到接收到一些数据或连接关闭。

原型

ssize_t recv(int sockfd, void *buf, size_t len, int flags);
  • sockfd:是一个指向已经建立连接的 socket 的文件描述符。
  • buf:是一个指针,指向缓冲区,这个缓冲区用于存储接收到的数据。
  • len:指定缓冲区的最大长度,即可以接收的数据的最大字节数。
  • flags:用于指定接收行为的附加标志。常用的标志包括:
    • MSG_PEEK:查看数据但不从系统缓冲区中移除。
    • MSG_WAITALL:等待直到请求的所有数据量填满缓冲区。
    • MSG_DONTWAIT:使 recv 操作非阻塞,即使数据还未准备好。

返回值

  • 成功:返回实际读取的字节数,如果连接已经正常关闭,则返回 0。
  • 失败:返回 -1,并设置 errno 以指示错误。

示例代码

98dff09cfee3e42f5d93702e75320a923068cad2e58753c387badbe8e5593722

这个例子展示了客户端如何创建一个 socket,连接到服务器,然后接收来自服务器的数据。

注意事项

  • 在接收数据之前,确保 socket 连接已经成功建立。
  • recv 函数的阻塞行为取决于 socket 的属性和 flags 参数。如果没有数据可读,recv 会阻塞调用线程,直到有数据为止。
  • 如果网络连接突然中断,recv 可能返回 -1,并且 errno 会设置为 ECONNRESET。
  • 使用 recv 时,应考虑循环接收数据,直到接收到所有期望的数据或遇到连接关闭。
recvfrom 函数

说明

用于从一个socket接收数据,通常用于无连接的socket,如使用UDP协议的情况。这个函数除了能够接收数据外,还可以获取发送方的地址信息,这在无连接的通信协议中特别有用,因为每个数据包都可能来自不同的源。

默认情况下会阻塞,直到接收到一些数据。

原型

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
  • sockfd:是一个socket描述符,指向一个已经打开的socket。
  • buf:指向用于接收数据的缓冲区的指针。
  • len:指定缓冲区的大小,即可以接收的最大数据量。
  • flags:用于修改操作的行为。常用的标志包括MSG_PEEK(查看数据但不从队列中删除)和MSG_WAITALL(等待所有请求的数据到达)等。
  • src_addr:(可选)一个指向struct sockaddr的指针,用于存储发送方的地址信息。
  • addrlen:一个指向socklen_t变量的指针,此变量在调用前应设定为src_addr结构的大小,调用后,此变量将被设置为实际接收到的地址的长度。

返回值

  • 成功:返回接收到的字节数,如果连接已经正常关闭,则返回0。
  • 失败:返回-1,并设置errno以指示错误。

示例代码

731e343007b2249355ca2ab116f9cf55ad4c73d36edf02856302aa24ebf2a77e

注意事项

  • 用recvfrom时,如果src_addr和addrlen不为NULL,发送者的地址信息会被填充到src_addr指向的结构中,并更新addrlen指向的值。
  • recvfrom通常用于无连接的协议(如UDP),但它也可以用于连接的socket。
  • 对于非阻塞socket,如果没有数据可用,recvfrom可能返回-1并设置errno为EAGAIN或EWOULDBLOCK。
sendto 函数

说明

用于通过一个无连接的socket发送数据到指定的目标地址,通常与UDP协议一起使用。这使得sendto成为在无连接协议下发送数据的理想选择,因为它允许每次调用时指定目的地地址。

默认情况下会阻塞,直到所有数据都被发送(或部分数据被发送)。

原型

ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
  • sockfd:是一个已经打开的socket描述符。
  • buf:指向待发送数据的缓冲区的指针。
  • len:指定缓冲区中数据的字节数,即要发送的数据长度。
  • flags:用于修改消息的发送行为的标志位。常用的标志包括 MSG_CONFIRM, MSG_DONTROUTE, 和 MSG_NOSIGNAL 等。
  • dest_addr:指向一个 struct sockaddr 结构体,该结构体包含目标地址的信息。对于IPv4,这通常是一个 struct sockaddr_in 结构体。
  • addrlen:指定目标地址结构的实际大小。

返回值

  • 成功:返回实际发送的字节数。
  • 失败:返回-1,并设置 errno 以指示错误。

示例代码

5763a18594bcb3c73829f4f5d789a42b4272fb0c159b4e030cae33ac0d01add0

注意事项

  • sendto是非连接函数,每次发送数据时都需要指定完整的目的地址。
  • 使用sendto时不需要连接socket,这与TCP协议下使用的send函数不同。
  • 如果sendto返回值小于预期,说明未能发送完整数据,可能需要额外处理以确保所有数据被发送。
  • 对于非阻塞socket,如果网络暂时无法发送数据,sendto可能返回-1并设置errno为EAGAIN或EWOULDBLOCK。
htons 函数

说明

将16位的数从主机字节序转换为网络字节序。主机字节序(host byte order)是指计算机内存中保存数据的格式,这可以是大端或小端,取决于处理器架构。网络字节序(network byte order)是一种统一的数据表示格式,用于在网络上发送数据,通常是大端格式。

原型

uint16_t htons(uint16_t hostshort);

用途

通常用于将端口号从主机字节序转换为网络字节序,因为在网络上传输端口号时必须使用网络字节序。例如,在设置socket地址结构体时,需要用到htons来处理端口号。

inet_aton 函数

说明

将点分十进制的IP地址字符串转换为用于网络通信的数值格式。它是inet_addr的增强版本,提供更好的错误处理。该函数将IP地址转换为一个in_addr结构,该结构包含一个能直接用于网络通信的二进制数

原型

int inet_aton(const char *cp, struct in_addr *inp);

返回值

如果点分字符串有效,函数返回非零值(通常是1),如果输入地址不正确,返回0。

用途

在设置socket地址结构时,常用inet_aton来处理IP地址。与inet_addr相比,inet_aton提供的地址转换错误处理能力更强,因为它能明确地区分失败的情况(返回0),而inet_addr在失败时返回INADDR_NONE(通常为-1),这在一些特定的IP地址(如255.255.255.255)上会导致歧义。


说完函数,下面列出TCP的编写思路:

57586484b6fe41d7ea4502a3134dc541e62211269132b34f30fedcc75aebdb42

UDP的编写思路:

2328786d643651d42abaa9faee0e40d9c47f0794fbe6f55ee949f39489b7a293

多线程编程

进程与线程的区别

定义:

  • 进程:是一个程序的运行实例。每个进程都有自己的代码和数据段以及独立的地址空间,即进程包含了执行程序所需的各种资源。进程之间的资源通常是隔离的,每个进程至少包含一个线程。
  • 线程:是进程中的一个实体,被系统独立调度和分派的基本单位。线程自身几乎不拥有系统资源(只保留必要的一些信息如程序计数器、一组寄存器和栈),但它可以与同属一个进程的其他线程共享进程所拥有的全部资源。

资源消耗和开销:

  • 进程:比线程消耗更多的资源。创建或撤销进程时,涉及到对资源(如内存和文件句柄)的分配或回收,因此相对耗时。
  • 线程:相比进程,线程的创建和撤销所需的资源较少,因为它们可以共享大部分进程资源,线程之间的切换开销也小于进程切换。

通信方式:

  • 进程:不同进程间的通信需要依赖于操作系统提供的机制,如管道、信号、套接字、共享内存等。
  • 线程:由于线程可以直接读写进程中的数据段,它们之间的通信更为方便,可以直接通过读写共享数据来进行。

独立性:

  • 进程:具有较强的独立性和稳定性,一个进程崩溃通常不会影响到其他进程。
  • 线程:独立性较弱,一个线程的错误可能会影响到同一进程中的其他线程。

执行过程:

  • 进程:每个独立的进程有自己的完整程序执行流程。
  • 线程:多个线程可以并发执行,提高了程序的执行效率,尤其是在多核处理器上运行时。
线程状态

线程有两种状态:可结合状态和分离状态。

可结合状态:

  • 默认情况下,线程是可结合状态的。
  • 当线程终止时,它的资源不会立即被释放,而是保持一些状态信息,以便其他线程可以调用pthread_join来获取它的退出状态,并回收其资源。
  • 如果没有调用pthread_join,这些资源将不会被释放,可能会导致资源泄漏。

分离状态(Detached State):

  • 当线程被设置为分离状态时,它在终止时,其资源会自动被操作系统回收。
  • 分离状态的线程不需要也不能被其他线程pthread_join
  • 这是适用于不需要等待其完成的后台线程。
线程创建
pthread_create 函数

说明

用于创建一个新线程的函数。这个函数的用途非常广泛,在多线程编程中,当你需要并行处理任务或者想要提高程序的执行效率时,pthread_create 是基础且关键的工具。

原型

int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_rountine)(void *), void *arg);
  • pthread_t *thread:指向 pthread_t 类型的指针,该变量在函数返回时将包含新创建线程的 ID。
  • const pthread_attr_t *attr:指向线程属性对象的指针。可以指定线程的各种属性(如堆栈大小、调度策略等)。如果传递 NULL,则使用默认属性。
  • void *(*start_routine)(void *):线程将执行的函数。这个函数必须接收一个 void* 类型的参数,并返回一个 void* 类型的值。
  • void *arg:传递给 start_routine 的参数。可以用来传递变量、结构体或者类实例的指针。

返回值

  • 成功时,pthread_create 返回 0。
  • 失败时,返回错误码,如 EAGAIN(无法创建更多的线程)或 EINVAL(传递了无效的属性)

示例

2641f4aef4465b3ca9f90707031d7c84f969699a87cbe0cdb244aae2469372db

注意事项

  • 在使用 pthread_create 时,应该确保传入的指针在线程生命周期内保持有效。
  • 线程创建后,通常需要使用 pthread_join 或类似机制来同步,这样主线程可以等待一个或多个线程完成。
  • 线程属性(如果使用的话)可以控制线程的行为,如调度策略和堆栈大小等,但在简单应用中通常不必修改默认属性。
线程的退出与回收

线程退出的情况有三种:

  • 进程结束,进程中所有的线程也会随之结束
  • 通过函数 pthread_exit 主动退出线程
  • 被其他线程调用 pthread_cancel 被动退出
pthread_join 函数

说明

用于等待指定线程终止。

在多线程编程中,你经常需要在一个线程(通常是主线程)中等待其他线程完成工作。使用 pthread_join 可以实现这种线程间的同步,确保所有线程按照期望的方式执行完毕。

原型

int pthread_join(pthread_t thread, void **retval);
  • pthread_t thread:要等待的线程标识符。这个标识符是由 pthread_create 函数创建线程时生成的。
  • void **retval:用于接收线程退出时的返回值的指针的指针。如果不关心线程的返回值,可以传递 NULL。

返回值

  • 成功时,pthread_join 返回 0。
  • 失败时,返回一个错误码,例如:
    • EINVAL:指定的线程不是一个可联接的线程,或者另一个线程已经在等待这个线程终止。
    • ESRCH:没有找到与给定线程ID对应的线程。

示例

5e5c75e3a5cda3d20b2854bbc254a4341dc8d8e75acbe97f1d24430fd8cf44bd

注意事项

  • 确保尝试联接的线程是可联接的(即没有被设置为“分离状态”)。
  • 当一个线程已经被联接后,它的线程ID和资源可以被重用。在这之前,它们保留在系统中(即使线程已经结束运行),这可以避免资源泄露。
  • 使用 pthread_join 时,应确保不会导致死锁。例如,两个线程互相等待对方完成将会导致死锁。
  • 如果线程已经结束,pthread_join 会立即返回;如果线程还在运行,pthread_join 会阻塞调用线程,直到目标线程结束。
pthread_exit 函数

说明

用于显式地退出一个线程,结束当前线程的执行并可以选择性地返回一个值给其他线程,例如被 pthread_join 调用的线程。这个函数是 POSIX 线程库的一部分,用于从线程内部直接终止线程,而不必等待线程的启动例程自然完成。

原型

void pthread_exit(void *retval);

void *retval:指向返回值的指针。这个值可以被其他线程(通过 pthread_join 调用)捕获。如果不需要返回任何值,可以传递 NULL。

示例

61c3e8af26519bbdf6f12bed2729716b1df8b9e76c9e80bba8fbff0d43e50502

注意事项

  • pthread_exit 调用之后,线程将停止执行,不会再运行任何后续的代码。
  • 如果主线程(main 函数)中调用了 pthread_exit,它将导致主线程退出,但不会影响其他仍在运行的线程。这与 exit 函数不同,后者会终止整个进程。
  • 传递给 pthread_exit 的指针应该指向一个在线程退出后仍然有效的内存位置,以避免悬挂指针和无效访问。
互斥量-线程的访问共享资源控制

多线程编临界资源访问

当线程在运行过程中,去操作公共资源,如全局变量的时候,可能会发生彼此“矛盾”现象。例如线程 1 企图想让变量自增,而线程 2 企图想要变量自减,两个线程存在互相竞争的关系导致变量永远处于一个“平衡状态”,两个线程互相竞争,线程 1 得到执行权后将变量自加,当线程 2 得到执行权后将变量自减,变量似乎永远在某个范围内浮动,无法到达期望数值。

358e8cd22e6b49bb8a262c0071b4113bc4bfdbadd0f84edc40611748ce497c40

互斥锁的作用:多个线程都要访问某个临界资源,比如某个全局变量时,需要互斥地访问:我访问时,你不能访问。

下面列出互斥锁的相关函数:

phread_mutex_init 函数

说明

  • 用于初始化 POSIX 线程中的互斥锁(mutex)。
  • 互斥锁是同步原语的一种,用于管理对共享资源的访问,确保在任何时刻只有一个线程可以访问该资源。初始化互斥锁是在使用它之前必须执行的步骤。

原型

int phread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
  • pthread_mutex_t *mutex:指向将要被初始化的互斥锁的指针。
  • const pthread_mutexattr_t *attr:指向一个互斥锁属性对象的指针,这个属性对象可以指定互斥锁的各种属性(如是否是递归锁)。如果传递 NULL,将使用默认属性。

返回值

  • 成功时,pthread_mutex_init 返回 0。
  • 失败时,可能返回如下错误码:
    • EINVAL:提供了无效的属性。
    • ENOMEM:没有足够的内存来初始化互斥锁。

示例

f446bc4a8e62f9b03c8369c321420fc3a2bbfb89540a74a0cac56eccc0b1d9cf

在这个程序中,我们定义了一个全局的 pthread_mutex_t 类型的互斥锁 lock,然后在 main 函数中初始化它。两个线程被创建来执行 do_work 函数,在这个函数中,它们尝试获取互斥锁来保护打印语句执行的独占性。最后,在所有线程结束后,互斥锁被销毁。

注意事项

  • 在使用完互斥锁之后,应该使用 pthread_mutex_destroy 函数来销毁互斥锁,释放任何可能的资源。
  • 如果在互斥锁使用过程中程序异常退出,可能会导致互斥锁未正常释放,从而引发死锁等问题。应当适当使用异常处理机制确保互斥锁能够被释放。
  • 互斥锁的属性可以通过 pthread_mutexattr_initpthread_mutexattr_settype 等函数设置,例如设置为递归锁,允许同一个线程多次锁定。
phread_mutex_lock 函数

说明

  • 用于对互斥锁(mutex)进行加锁操作。
  • 这个函数试图锁定一个互斥锁,如果该互斥锁已经被另一个线程锁定,调用该函数的线程将被阻塞直到互斥锁变为可用。这是实现线程间同步和保护共享资源免受并发访问问题的关键操作。

原型

int pthread_mutex_lock(pthread_mutex_t *mutex);
  • pthread_mutex_t *mutex:指向要锁定的互斥锁的指针。

返回值

  • 成功时,pthread_mutex_lock 返回 0。
  • 失败时,可能返回如下错误码:
    • EINVAL:传递了无效的互斥锁指针。
    • EDEADLK:当前线程已经锁定该互斥锁(如果互斥锁是不可递归的),再次尝试锁定会导致死锁。

示例

92f4a098164b854f0525a860726657cac634c68ca3cde56f3a55b5c6a958d640

在这个例子中,lock 是一个全局的互斥锁。在 do_work 函数中,每个线程首先尝试获取互斥锁。如果锁已经被另一个线程占用,线程将被阻塞,直到互斥锁被释放。这确保了 printf 和 sleep 操作在任何时刻只能由一个线程执行。

注意事项

  • 死锁问题:当使用互斥锁时,应当注意避免死锁情况,特别是在多个互斥锁交互时。
  • 锁定和解锁匹配:每次成功的 pthread_mutex_lock 调用都应该有对应的 pthread_mutex_unlock 调用来释放互斥锁,以免造成资源泄漏或死锁。
  • 阻塞行为:pthread_mutex_lock 默认情况下是阻塞的,如果你需要非阻塞行为,可以使用 pthread_mutex_trylock,它会立即返回而不是阻塞等待。
phread_mutex_unlock 函数

说明

  • 用于释放互斥锁(mutex)
  • 当一个线程完成对共享资源的访问后,应该释放之前获得的锁,以允许其他线程访问该资源。这是保持程序正常运作的重要环节,确保资源不会被永久锁定。

原型

int pthread_mutex_unlock(pthread_mutex_t *mutex);
  • pthread_mutex_t *mutex:指向需要被释放的互斥锁的指针。

返回值

  • 成功时,pthread_mutex_unlock 返回 0。
  • 失败时,可能返回如下错误码:
    • EINVAL:传递了无效的互斥锁指针。
    • EPERM:当前线程不拥有该互斥锁,试图释放不属于自己的锁。

示例

c439546cbd4fec53f901e33a053caac178e7bff5cbb1962e3970ab49b4e80ea0

在这个程序中,互斥锁 lock 被用来保证在任何时刻只有一个线程可以执行打印和模拟工作的代码段。每个线程在完成工作后会释放锁,允许另一个线程获取锁并执行相同的操作。

注意事项

  • 正确匹配:每次调用 pthread_mutex_lock 后必须相应地调用 pthread_mutex_unlock,以避免造成死锁。
  • 避免错误释放:只有加锁成功的线程才能解锁,试图释放未成功加锁的互斥锁会导致 EPERM 错误。
  • 互斥锁状态检查:在调用 pthread_mutex_unlock 前,确保你的应用逻辑已确实持有锁。错误的解锁可能导致不可预见的行为。
phread_mutex_destory 函数

说明

用于销毁一个已经初始化的互斥锁(mutex),释放与它相关的任何资源。这个函数是 POSIX 线程库中的一部分,通常在程序结束前或者当互斥锁不再需要时调用,以确保资源被正确回收。

原型

int pthread_mutex_destroy(pthread_mutex_t *mutex);
  • pthread_mutex_t *mutex:指向需要被销毁的互斥锁的指针。

返回值

  • 成功时,pthread_mutex_destroy 返回 0。
  • 失败时,可能返回如下错误码:
    • EBUSY:如果试图销毁一个仍被锁定或引用(例如其他线程正在等待锁定它)的互斥锁,则返回此错误。

示例

30b147b7dcbfcd83ed34e5d2774b4b6b7b07f863496684ae5813c1ebea187c83

在这个程序中,我们定义了一个全局的 pthread_mutex_t 类型的互斥锁 lock,并在主函数中初始化和销毁它。两个线程被创建来执行 thread_function,在这个函数中,它们获取并最终释放互斥锁。程序结束前,互斥锁被销毁以确保资源被正确释放。

注意事项

  • 确保无锁定状态:在销毁互斥锁之前,确保没有线程正在使用它(即互斥锁处于未锁定状态)。尝试销毁一个正在被使用的互斥锁会导致 EBUSY 错误。
  • 重复销毁:避免对同一个互斥锁多次调用 pthread_mutex_destroy,这可能导致未定义行为。
  • 释放后使用:一旦互斥锁被销毁,任何进一步对该锁的操作都是未定义的,因此确保在销毁后不再引用该锁。

编写思路

有了上面的前置知识之后,我们就可以来实现一个简易的网络聊天室了。

我们的网络聊天室要实现这样一个功能:服务器启动服务之后,客户端可以连接上服务器,服务器支持多个客户端连接,客户端连接前需要输入自己的昵称,连接成功后就可以在终端发送消息,只要是已连接的客户端,都可以看到相互发送的消息。当客户端退出后,服务器会打印某某客户端已退出聊天室。

以上就是一个网络聊天室功能的大致描述,下面来讲下编写的注意事项。

注意事项
  1. 首先,我们要先选择网络协议,根据TCP和UDP的特点,我们需要的是一个稳定、可靠的协议,因此选择TCP作为我们的网络协议。
  2. 服务器接收到一个连接后,要创建相应的客户端线程执行对应的任务,让主线程可以继续接收新的TCP连接,这个过程中,可能出现资源竞争的情况,因此需要用到互斥量。
  3. 在用malloc分配内存后,记得用free释放内存,防止内存泄漏。

下面给出具体的代码,并标注了具体的注释,相信大家一看就懂~

代码

服务端

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

#define MAX_CLIENTS 10
#define BUFFER_SIZE 1024
#define NAME_SIZE 20
#define PORT 8823

// 结构体表示连接到服务器的客户端
struct client {
  char name[NAME_SIZE]; // 客户端的名称
  int sockfd;           // 与客户端通信的套接字文件描述符
};

// 存储指向客户端结构体的指针的数组
static struct client *clients[MAX_CLIENTS];
static int client_count = 0; // 连接的客户端数量
static pthread_mutex_t client_mutex; // 用于线程安全访问客户端数据的互斥锁

// 广播消息给所有连接的客户端
void broadcast_message(const char *message) {
  pthread_mutex_lock(&client_mutex); // 锁定互斥锁以防止并发访问客户端数据
  for (int i = 0; i < client_count; i++) {
    send(clients[i]->sockfd, message, strlen(message), 0); // 向每个客户端发送消息
  }
  pthread_mutex_unlock(&client_mutex); // 广播消息后解锁互斥锁
}

// 处理与客户端通信的函数,在单独的线程中运行
void *handle_client(void *client_socket) {
  struct client *client = (struct client *)client_socket;
  char buffer[BUFFER_SIZE];
  int bytes_read;

  // 接收来自客户端的消息,直到连接关闭
  while ((bytes_read = recv(client->sockfd, buffer, BUFFER_SIZE, 0)) > 0) {
    buffer[bytes_read] = '\0'; // 给接收到的消息添加空字符终止符
    printf("%s: %s\n", client->name, buffer); // 在服务器控制台上打印客户端的名称和消息

    // 创建要广播给所有客户端(包括发送者)的消息
    char *message = malloc(strlen(client->name) + strlen(buffer) + 4);
    sprintf(message, "%s: %s", client->name, buffer);
    broadcast_message(message); // 广播消息给所有客户端
    free(message); // 释放为消息分配的内存
  }

  printf("%s 已退出聊天室\n", client->name); // 当客户端断开连接时打印消息

  // 关闭客户端套接字并从数组中移除客户端
  pthread_mutex_lock(&client_mutex); // 锁定互斥锁以安全地访问客户端数据
  close(client->sockfd); // 关闭客户端套接字
  for (int i = 0; i < client_count; i++) {
    if (clients[i]->sockfd == client->sockfd) {
      // 通过移动后续元素来从数组中移除客户端
      for (int j = i; j < client_count - 1; j++) {
        clients[j] = clients[j + 1];
      }
      client_count--; // 减少客户端数量
      break;
    }
  }
  free(client); // 释放为客户端结构体分配的内存
  pthread_mutex_unlock(&client_mutex); // 修改客户端数据后解锁互斥锁

  pthread_exit(NULL); // 退出线程
}

// 主函数
int main(void) {
  int sockfd, client_sockfd; // 套接字文件描述符
  struct sockaddr_in addr; // 地址结构
  int addrlen = sizeof(addr); // 地址结构长度
  pthread_mutex_init(&client_mutex, NULL); // 初始化互斥锁

  // 创建套接字
  if ((sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
    perror("Failed to create socket");
    exit(EXIT_FAILURE);
  }

  // 设置服务器地址
  addr.sin_family = AF_INET;
  addr.sin_port = htons(PORT);
  addr.sin_addr.s_addr = INADDR_ANY;

  // 将套接字绑定到地址
  if (bind(sockfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
    perror("Bind failed");
    close(sockfd);
    exit(EXIT_FAILURE);
  }

  // 监听客户端连接
  if (listen(sockfd, MAX_CLIENTS) < 0) {
    perror("Listen failed");
    close(sockfd);
    exit(EXIT_FAILURE);
  }

  printf("服务启动成功,进入监听状态\n"); // 打印消息表示服务器正在运行并监听客户端连接

  // 接受传入的客户端连接
  while (1) {
    struct sockaddr_in client_addr;
    if ((client_sockfd = accept(sockfd, (struct sockaddr *)&client_addr,
                                (socklen_t *)&addrlen)) < 0) {
      perror("Accept failed");
      break;
    }

    pthread_mutex_lock(&client_mutex); // 锁定互斥锁以安全地访问客户端数据
    if (client_count < MAX_CLIENTS) {
      // 为客户端结构体分配内存
      struct client *client = malloc(sizeof(struct client));
      // 接收客户端的名称
      recv(client_sockfd, client->name, NAME_SIZE, 0);

      // 初始化客户端结构体并添加到数组中
      client->sockfd = client_sockfd;
      clients[client_count] = client;

      printf("%s 进入聊天室\n", client->name); // 打印消息表示客户端已加入

      client_count++; // 增加客户端数量
      // 创建线程来处理客户端通信
      pthread_t thread;
      pthread_create(&thread, NULL, handle_client, client);
      pthread_detach(thread); // 分离线程以允许其独立运行
    } else {
      printf("Maximum clients conneted. Connection refused.\n"); // 如果达到最大客户端数量则打印消息
      close(client_sockfd); // 关闭客户端套接字
    }
    pthread_mutex_unlock(&client_mutex); // 修改客户端数据后解锁互斥锁
  }

  close(sockfd); // 关闭服务器套接字
  return 0;
}

客户端

#include <arpa/inet.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

#define PORT 8823
#define BUFFER_SIZE 1024
#define NAME_SIZE 20

// 函数:接收消息的线程函数
void *receive_messages(void *socketfd_p) {
  int sockfd = *((int *)socketfd_p); // 获取套接字文件描述符
  char buffer[BUFFER_SIZE]; // 用于存储接收到的消息
  int len; // 接收消息的长度
  // 循环接收消息直到连接关闭
  while ((len = recv(sockfd, buffer, BUFFER_SIZE, 0)) > 0) {
    buffer[len] = '\0'; // 添加字符串终止符
    printf("%s\n", buffer); // 在控制台打印接收到的消息
  }
  pthread_exit(NULL); // 退出线程
}

int main(void) {
  int socketfd; // 套接字文件描述符
  struct sockaddr_in addr; // 地址结构
  pthread_t recv_thread; // 接收消息的线程
  char buf[BUFFER_SIZE]; // 用于存储用户输入的消息
  char name[NAME_SIZE]; // 用户的名称

  char *tips = "请输入名字,长度不超过5:\n"; // 提示用户输入名称的消息
  fwrite(tips, sizeof(char), strlen(tips), stdout); // 将提示消息写入标准输出

  fgets(name, NAME_SIZE, stdin); // 从标准输入获取用户输入的名称

  name[strlen(name) - 1] = '\0'; // 去除名称末尾的换行符

  printf("name: %s\n", name); // 在控制台打印用户的名称

  if ((socketfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) { // 创建套接字
    perror("Failed to create socket"); // 打印错误信息
    exit(EXIT_FAILURE); // 退出程序
  }

  addr.sin_port = htons(PORT); // 设置端口号
  addr.sin_family = AF_INET; // 设置地址族为IPv4

  // 将IP地址从文本格式转换为二进制格式
  if (inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr.s_addr) <= 0) {
    perror("Invalid address/Address not supported"); // 打印错误信息
    exit(EXIT_FAILURE); // 退出程序
  }

  // 连接到服务器
  if (connect(socketfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
    perror("Connection Failed"); // 打印错误信息
    exit(EXIT_FAILURE); // 退出程序
  }

  // 创建线程用于接收消息
  pthread_create(&recv_thread, NULL, receive_messages, &socketfd);

  // 发送用户名到服务器
  send(socketfd, name, strlen(name), 0);

  // 循环获取用户输入并发送消息到服务器
  while (1) {
    fgets(buf, BUFFER_SIZE, stdin); // 从标准输入获取用户输入的消息
    send(socketfd, buf, strlen(buf), 0); // 发送消息到服务器
  }

  return 0; // 程序正常退出
}

makefile

files = client server

all: $(files)

client: client.c

server: server.c

clean:
	rm -f client server

运行

# 编译项目
make

# 运行服务器
./server

# 运行客户端
./client

image-20240529212901878

总结

相信通过对这个网络聊天室的编写,我们可以对网络编程和多线程编程相关知识有更进一步的了解。当然这个网络聊天室还有非常多可以完善的地方,比如加入线程池、完善收发机制等等。

  • 22
    点赞
  • 35
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值