线程的互斥

目录

线程互斥

线程互斥的背景知识

多线程抢票

多线程抢票加锁

锁的原理

可重入函数与线程安全

常见的线程安全的情况

常见的不可重入情况

常见的可重入情况

总结


线程互斥

线程互斥的背景知识

临界资源:临界资源就是多个执行流共享的资源就叫做临界资源。

临界区:访问临界资源的代码就叫做临界区。

互斥:就是在任何时刻,只能有一个执行流进入进入临界区然后访问临界资源,可以对临界资源起到保护作用。

原子性:不会被任何调度打断,且只有两态,要么完成,要么未完成。

上一次我们说了关于线程的控制,而我们就是通过多线程来执行一些代码,提高效率(但是不是线程越多越好,而且有些场景不适合多线程)。

而且我们也说了,多线程是比较容易出错的,所以我们通过写代码来发现多线程容易出错的问题,然后来慢慢调整。

如果由多线程访问临界资源然后导致出现问题,那么我们就可以选择对临区进行加锁,来保证多线程执行的正确性。

多线程抢票

下面呢,我们要写一个关于多线程抢票的代码:

我们创建一批线程,然后让这批线程区执行一个抢票(对一个变量进行减减操作):

#define THREAD_NUM 5
​
int tickets = 10000;
​
void *threadRun1(void *args)
{
    while (true)
    {
        if (tickets > 0)
        {
            // 模拟抢票前需要做的事
            usleep(rand() % 3000 + 500);
            
            printf("%d : 抢了第 %d 张票\n", (long long)args, tickets);
            --tickets;
        }
        else
        {
            break;
        }
    }
    return nullptr;
}
​
// 多线程抢票逻辑
void test1()
{
    // 创建多线程
    vector<pthread_t> thread_num(THREAD_NUM);
    for (int i = 0; i < THREAD_NUM; ++i)
    {
        pthread_create(&thread_num[i], nullptr, threadRun1, (void *)(i + 1));
    }
​
    // 循环等待每个线程
    for (int i = 0; i < THREAD_NUM; ++i)
    {
        pthread_join(thread_num[i], nullptr);
    }
}

如果在我们平时的情况下,这个代码抢到 1 就会停下,那么现在我们看一下。

结果:

4 : 抢了第 6 张票
4 : 抢了第 5 张票
3 : 抢了第 4 张票
5 : 抢了第 3 张票
2 : 抢了第 2 张票
4 : 抢了第 1 张票
1 : 抢了第 0 张票
2 : 抢了第 -1 张票
5 : 抢了第 -2 张票
3 : 抢了第 -3 张票
[lxy@hecs-348468 mutex]$ 

这里看到,我们前面的抢票还抢到了负数,那么这是什么情况?

下面我们为大家解释:

我们前面说了,线程事CPU调度的基本单位,由于操作系统中有很多线程,所以为了公平,系统京可能让所有的线程都执行相同的时间,而线程也随时都可能被切换。

虽然说线程被切换,但是其实并没有问题,因为在线程被切换的时候,线程会把CPU中的上下文数据带走,当这个线程回来的时候,会进行上下文恢复,然后继续执行代码。

所以我们知道线程事随时都可能被切换走的。

看这幅图,首先,我们在 if 条件判断那里,判断也是计算的一种,所以需要加载到CPU里面,然后进行判断,如果判断发现大于0,那么就可以进入到 if 里面了,然后执行ticket--操作。

由于这个判断编译成汇编之后,并不是一条指令,所以这个判断也是分为几步的。

由于我们知道,进程随时都可能被切换走,所以如果刚好判断发现大于0,后然后被切换走了,线程此时已经静茹到 if 条件里面了,此时被切换走后,该线程会保护自己的上下文数据,然后也会记录自己执行到哪里了,等下一次回来后接着执行。

然后此时其他的线程又进来了,同样发现大于0,也对 tickets-- 操作,然后经过多次减减操作后,最终减为0,但是此时被切换走的哪个线程又被系统调度了,此时它回复上下文后,就准备执行后续的代码,也就是对 tickets--,但是此时的 tickets 已经被减为0了,不能才减了,也就是票已经售空了,但是被调度的进程在前面已经判断过了,进入了 if 语句,所以此时还是对 tickets 进程 减减操作,此时就出现问题了,线程不安全。

所以就需要对临界区进程加锁。

多线程抢票加锁

这里我们先直接看如何进行加锁,以及加锁的函数以及代码如何编写,后面我们在说一下加锁的原理。

pthread_mutex_init

NAME
       pthread_mutex_destroy, pthread_mutex_init - destroy and initialize a mutex
​
SYNOPSIS
       #include <pthread.h>
​
       int pthread_mutex_init(pthread_mutex_t *restrict mutex,
              const pthread_mutexattr_t *restrict attr);
  • 这个函数是加锁,可以让临界区只能由一个执行流进入。

  • 第一个参数是一个 pthread_mutex_t 的指针,也就是我们说的锁,我们需要对锁进行初始化。

  • 第二个参数是一个关于设置锁的属性的变量,我们设置为 nullptr 即可。

  • 但是我们也可以不对锁锁进行这样的初始化,如果是全局的锁,那么我们可以直接使用一个宏来初始化。

  • 全局锁的初始化,可以使用——PTHREAD_MUTEX_INITIALIZER

下面再介绍三个关于锁的常用操作 PV 操作:

NAME
       pthread_mutex_lock, pthread_mutex_trylock, pthread_mutex_unlock - lock and unlock a mutex
​
SYNOPSIS
       #include <pthread.h>
​
       int pthread_mutex_lock(pthread_mutex_t *mutex);
       int pthread_mutex_trylock(pthread_mutex_t *mutex);
       int pthread_mutex_unlock(pthread_mutex_t *mutex);
  • 首先第一个函数就是加锁,我们看函数名也可以看出来,该函数如果没有竞争到锁的话,那么就会阻塞住。

  • 第二个函数也是加锁的,但是这个函数如果没有竞争到锁的话,就会返回。

  • 第三个函数就是解锁。

  • 而这三个函数的参数分别是锁的指针。

下面我们写一个代码,对临界区进行加锁:

下面为了简单一点,我们这一次使用全局的锁。

​
// 为了简单一点,这里先使用全局的锁
// 全局的锁可以使用系统中的一个宏来初始化,如果不是全局的,那么就需要使用 pthread_mutex_init 来初始化

pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;

​
// 加锁条件下测试多线程抢票是否会异常
void *threadRun2(void *args)
{
    while (true)
    {
        pthread_mutex_lock(&mtx);
        if (tickets > 0) // 这里虽然是判断,但是也是计算的一种,所以加锁需要对判断也进行加锁
        {
            printf("%ld : 抢了第 %d 张票\n", (long long)args, tickets);
            int tmp = tickets--;
            --tickets;
            pthread_mutex_unlock(&mtx);
        }
        else
        {
            pthread_mutex_unlock(&mtx);
            break;
        }
        // 这里为了模拟抢票后还需要做的事情,这里就用sleep代替
        usleep(rand() % 1500 + 500);
    }
​
    return nullptr;
}
​

这里我们再 if 之前就需要加锁,为什么?

因为我们前面也说了,判断也是计算,因为后面可能会修改 tickets 所以需要对 if 判断也需要加锁。

那么什么时候解锁呢?

我们可不可以再else 后面解锁?为什么?

我不可以再else后面解锁,因为如果没有走 if 里面呢?而是直接走了else 逻辑,那么此时该进程持有锁,但是它却没有释放,然后直接退出了,这样会导致其他线程竞争锁,然后没有锁就会导致死锁的问题。

如果我们要写到 else 后面,那么我们还需要到 else 的逻辑里面也写一条解锁的代码,这样即使持有锁,那么再走了 else 的逻辑后,还会释放锁。

那么我们为什么不把解锁写到 else 后面呢?

实际上,我们知道,如果加锁了的话,那么此时这段代码就是串行的,那么就是只有一个执行流可以再同一时间执行,所以会导致效率低下,所以加锁的粒度是越细越好。

下面我们看一下试验结果:

1 : 抢了第 7 张票
5 : 抢了第 6 张票
4 : 抢了第 5 张票
1 : 抢了第 4 张票
2 : 抢了第 3 张票
3 : 抢了第 2 张票
5 : 抢了第 1 张票
[lxy@hecs-348468 mutex]$ 
​

这里我们看到结果就正确了。

但是下面我们由几个疑问:

  1. 加锁了,那么加锁的那一段是串行的吗?

  2. 加锁之后,就不会到临界区被切换吗?

  3. 如果被切换,那么是否安全呢?

下面我们就来回答这三个问题。

我们先回答第二个,加锁之后会不会到临界区被切换?

回答:会的! 我们前面也说过,线程随时都可能被切换,那么如果当这个线程执行到临界区,然后时间片到了,那么操作系统一定会把它从CPU上剥下来,然后换另一个线程上去执行,而且不光是时间片到了,如果是抢占式,那么还可能被优先级高的线程给抢占。

回答第三个问题:那么既然可以被抢占,那么切换后,临界资源是否安全呢?

回答:安全! 为什么?我们在进入临界区的时候,我们是先加锁的,那么也就是说,如果进入临界区说明该线程一定是尺有锁的,既然是持有锁,那么说明其他线程是没有锁的,既然如此,那么即使该线程被切换下去,系统将其他线程调度,其他线程因为没有锁而处于阻塞状态,所以也就不会调度其他的线程,等待该线程再一次被调度,首先是恢复上下文,该线程还是持有锁的,所以在此期间,其他的线程都不会访问临界资源,只有持有锁的线程可以访问,所以临界资源是安全的。

回答最后一个(第一个)问题:那么加锁后是串行执行吗?

回答:是的!其实我们回答了上面的两个问题后,我们也就理所当然的知道加锁后是串行执行的,但是串行执行的之哟临界区的代码,其他的代码还是可能是并行的,所以加锁后,临界区的代码执行是串行的。

上一个代码我们使用了全局的锁,这一次我们使用局部的锁,还可以在理解一下加锁:

// 用于将线程的名字和锁都传过去,如果还有其他数据需要传的话,那么就可以写到该结构体中
struct threadDate
{
    threadDate(int _thread_name, pthread_mutex_t* _mutex)
        :thread_name(_thread_name)
        ,mutex(_mutex)
    {}
​
    int thread_name = 0;
    pthread_mutex_t* mutex = nullptr;
};
​
​
// 下面是使用了局部的锁,所以需要加锁的话,那么就需要将锁传过来,可以通过 void* 进程传参
void *threadRun3(void *args)
{
    threadDate* date = reinterpret_cast<threadDate*>(args);// 相似类型转换
    while(true)
    {
        pthread_mutex_lock(date->mutex);
        if(tickets > 0)
        {
            int tmp = tickets--;
            pthread_mutex_unlock(date->mutex);
            printf("%ld 号线程: 抢到了 %d 张票\n", date->thread_name, tmp);
        }
        else 
        {
            pthread_mutex_unlock(date->mutex);
            break;
        }
        usleep(rand() % 1500 + 500);
    }
    delete date;
}
​
// 上面为了方便,使用了全局的锁,下面我们使用局部的锁
void test2()
{
    // 先创建一个局部的锁
    pthread_mutex_t mutex;
    // 需要对锁进程初始化
    pthread_mutex_init(&mutex, nullptr);
    cout << "锁初始化成功" << endl;
​
    // 创建多线程
    vector<pthread_t> thread_num(THREAD_NUM);
    for (int i = 0; i < THREAD_NUM; ++i)
    {   
        // 为了将锁和线程的其他数据传入到回调函数中,我们需要构造一个对象,用来存放每个线程的数据
        threadDate* date = new threadDate(i, &mutex);
        pthread_create(&thread_num[i], nullptr, threadRun3, (void *)date);
    }
​
    // 循环等待每个线程
    for (int i = 0; i < THREAD_NUM; ++i)
    {
        pthread_join(thread_num[i], nullptr);
        // cout << "等待线程 " << i << " 号线程成功" << endl;
    }
​
    // 使用完后需要对锁进行释放
    pthread_mutex_destroy(&mutex);
}

如果我们使用局部的锁,那么如果我们需要在线程中加锁的话,我们就需要把这个锁传入到线程中,那么怎么传入呢?我们在线程创建的时候,最后一个参数是一个 void 的指针,然后我们可以通过这个指针将锁传进去。

但是我们要是还想传入其他的数据呢?

我们可以用一个类/结构体来传入,我们可以将我们想要传入的数据放到一个对象中,然后将该对象的地址传进去。但是如果是局部的锁初始化后,那么我们就需要对这个锁进行释放,所以我们在使用完后还需要进行释放。

这个试验结果我们就不看了,因为这个和上面的结果一样。

锁的原理

上面我们以及使用过锁了,下面我们说一下锁的原理:

在C/C++中,对一个变量进行++/-- 操作的时候,我们看似是一条语句,但是实际上却不是一条语句。

当我们进行++的时候:

  1. 先将变量加载到寄存器中

  2. 然后对寄存器中的数据进程++

  3. 最后将该数据拷贝回内存。

而我们前面说了线程是什么时候都可能被切换的,那么我们可能对一个变量进行加加的时候,可能刚执行完第二步,然后就被切换走了。

而我们前面还说了一个原子性,原子性就是要么完成,要么未完成,而前面的这个加加/减减显然就不是原子的,因为有三条语句,而我们认为如果编译未汇编后只有一条语句,那么该语句就是原子的。

结论:如果是一条汇编,那么就是是原子性。

上面的一个结论以及知道了,下面我们在看一个。

前面我们访问的 tickets 是全局的数据,但是我们访问全局的数据的时候需要进行访问保护,而我们访问保护又需要锁,但是我们加锁又是多线程使用同一把锁,那么这个锁是不是临界资源呢?是的,那么当访问一个临界资源的时候需要加锁,但是锁也是临界资源,那么怎么办呢?

下面我们就谈一下锁的原理:

在谈这个之前,我们先介绍一个背景知识:

我们系统中可能又一条汇编可以让寄存器中的数,直接和内存中的数据交换:

swap / exchange #可以使用一条汇编,就可以将寄存器中的数据和内存中的数据交换

不同的操作系统可能不同,但是一定会有这条汇编,可能是 swap 也可能是 exchange。

那么锁我们应该怎么理解呢?下面我们可以把锁理解为内存中的一个值,我们认为锁就是 1,没有锁就是 0.

如果时 lock 的话,那么时怎么样加锁呢?

lock

lock:
    movb $0,%al
    swap %al,mutex
    if(%al > 0)
    {
        return;
    }
    else
    {
        阻塞...
        goto lock
    }

unlock

unlock:
    movb $1,mutex
    唤醒等待 mutex 的线程
    return

上面我们说了,汇编中有一条语句,可以帮助我们将寄存器中的数据和内存中的数据用一条汇编就可以完成。

我们认为,一条汇编就是原子的,也就是要么完成了,要么未完成。

既然如此那么先看依稀 lock 的代码:

  1. 首先将 0 move 到一个 %al 的寄存器中

  2. 使用一条汇编 swap 将 %al 中的数据和内存中 mutex 中的数据进行交换

  3. 判断 %al 中的数据是否是 1,如果是 1 的话,那么就说明竞争到锁了,那么如果是 0 的话,说明还是没有锁。

  4. 如果没有锁的话,就需要去 else 的逻辑中去阻塞。

  5. 如果有锁了,那么就直接返回,可以执行加锁后的代码。

  6. 既然锁以及被该进程拿走了,此时内存中 mutex 中的值就是 0,此时其他的线程来了,也经过上面的操作,发现内存中的数据是 0,所以就会进入到 else 的逻辑,阻塞起来。

下面看一下 unlock 的代码:

其实 unlock 就比较简单,此时能执行 unlock 一定说明你是加锁了的(在逻辑正确的情况下),那么既然内存中的数据是 0 ,那么就可以将 1 直接放到内存中的 mutex 中,所以这就完成了解锁,解锁之后还需要对阻塞的线程进行唤醒。

可重入函数与线程安全

下面再说一下比较容易混淆的两个概念:可重入与线程安全

线程安全:多线程情况下,同时并发访问梯段代码,不会出现不同的结果,就是说明是线程安全的,一般情况下,如果对全局数据或者静态变量访问了,那么就容易是线程不安全的,当然还可能使 malloc 了,或者其他的操作。

重入:同一个函数被不同的执行流调用,再一个执行流还没有结束的时候,就有其他的执行流执行这段代码,那么就称之为重入,如果重入后,不会对结果产生影响,那么说明该函数时可重入的,如果重入后对结果产生了影响,那么说明该函数时不可重入的。

常见的线程安全的情况
  • 多线程场景下,对全局或者静态的变量具有只读权限,那么一般情况下,这样就是线程安全的。

  • 多线程锁调用的函数都是有原子性的,那么说明时线程安全的。

  • 多线程之间切换,不会导致接口调用出现二义性。

常见的不可重入情况
  • 调用了 malloc/free 等函数...

  • 调用了 IO 类的接口,一般的 IO 都是不可重入的

  • 可重入函数内使用了静态的数据结构

常见的可重入情况
  • 不使用 malloc/free 函数

  • 不使用 IO 类接口

  • 没有使用静态变量,全局变量

  • 没有调用不可重入函数

  • 不返回静态或者全局的数据,使用本地的变量

总结
  • 一般情况下,如果函数是可重入的,那么线程就是安全的。

  • 如果函数是不可重入的,那么说明多线程访问会有问题,所以就不能多线程访问。

  • 线程安全的不一定是可重入函数,而可重入函数一定是线程安全的。

  • 例如:如果对临界资源访问加上锁,那么说明这个是线程安全的,但是如果这个函数是可重入的,那么当锁还未释放的时候就重入了,此时会竞争锁,此时救护产生死锁,所以是不可重入的。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/138114.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

idea maven 构建本地jar包及pom文件

1、设置模块build 本地输出路径 <build><defaultGoal>compile</defaultGoal><resources><resource><directory>${basedir}/src/main/resources</directory><includes><include>**/**</include></includes>…

企业年报API:打开企业经营大数据的新视角

引言 随着数字化转型的深入推进&#xff0c;企业年报API作为企业信息化建设中的重要组成部分&#xff0c;受到了越来越多企业和机构的关注和重视。而且&#xff0c;随着中国经济的快速发展&#xff0c;企业年报也成为投资者、监管机构以及相关利益方了解企业经营状况的重要途径…

在以BUF,字节存储区中,存放有n个带符号整数。试编写统计其中负偶数个数(假设≤9)并且显示。

;默认认采用ML6.11汇编程序 DATAS SEGMENT;此处输入数据段代码BUF DB -2,2,3,4,-4N$-BUF DATAS ENDS STACKS SEGMENT;此处处输入堆栈段代码 STACKS ENDS CODES SEGMENTASSUME CS:CODES,DS: DATAS, SS:STACKS START:MOV AX, DATASMOV DS,AXMOV BX,0MOV CX,0 LOP: mov AX,[BX] RO…

成都优优聚美团代运营:打造高效电商运营的利器

一、引人注目的标题 在繁杂的电商市场中&#xff0c;成都优优聚美团代运营以其专业的服务&#xff0c;为商家提供了一站式的解决方案。那么&#xff0c;这个备受瞩目的代运营平台有何特别之处呢&#xff1f;今天&#xff0c;我们就来一探究竟。 二、平台背景与优势 成都优优聚…

微信小程序用户隐私API

用户隐私保护 由于用户隐私保护的政策执行&#xff0c;我们在调用涉及到用户隐私的API时&#xff0c;未更新用户隐私保护协议是无法直接调用的&#xff0c;小程序会默认判断是否更新用户隐私保护 &#xff0c;并根据用户隐私保护中的协议来判断是否可以调用对应的API&#xff…

5个高质量的实用办公软件,每一款都是良心推荐

在现代办公环境中&#xff0c;高效的办公软件可以极大地提升工作效率&#xff0c;简化工作流程&#xff0c;帮助我们更好地完成工作。今天就给大家分享5个高质量的实用办公软件&#xff0c;每一款都是良心推荐。 01、FastStone Capture&#xff08;截图工具&#xff09; FastSt…

安装表面应变计的方法及注意事项

安装表面应变计的方法及注意事项 表面应变计被广泛用于水利工程和混凝土结构中&#xff0c;用于测量埋设点的线性变形&#xff08;应变&#xff09;和应力&#xff0c;同时也可以测量温度。它们可以分为表面安装式和埋入式两种。 一、埋入式表面应变计 1、混凝土应变计的安装…

FAN73832MX 350mA-650mA 高压600V 能驱动MOSFET和IGBT 半桥栅极驱动IC

FAN73832MX是一款半桥、栅极驱动 IC&#xff0c;带关断和可编程死区时间控制功能&#xff0c;能驱动 MOSFET 和 IGBT&#xff0c;工作电压高达 600 V。飞兆的高压工艺和共模噪声消除技术可使高侧驱动器在高 dv/dt 噪声环境下稳定运行。先进的电平转换电路允许高侧驱动器的工作偏…

木柴的舒适:燃木壁炉带来的温暖

火的温暖和光芒一直伴随着我们。如今&#xff0c;虽然电暖和中央暖气等现代供暖方式逐渐普及&#xff0c;但燃木壁炉依然保留了其独特的魅力&#xff0c;成为许多人家居的亮点。 燃木壁炉在寒冷的冬天为您的家带来温暖。燃烧的木柴散发出温暖的光芒和令人陶醉的木材香气&#…

强大好用的shell:shell命令

命令名称&#xff1a;就是语法中的“动词”&#xff0c;表达的是想要做的事情&#xff0c;例如创建用户、查看文件、重启系统等操作。 命令参数&#xff1a;用于对命令进行调整让“修&#xff0c;改”过的命令能更好地贴合工作需求&#xff0c;达到事半功倍的效果。 命令对象&a…

Mysql学习笔记--高级

DQL高级查询 1&#xff0c; [掌握]limit分页查询 语法 select 字段名1,字段名2,… from 表名 limit M, N; M: 整数&#xff0c;表示从第几条索引开始&#xff0c;计算方式:(当前页-1) * 每页显示条数; N: 整数&#xff0c;表示查询多少条数据. 例如&#xff1a;select * from…

网页速度即体验速度:vueSPA(单页应用)首屏加载成就用户心动瞬间

&#x1f3ac; 江城开朗的豌豆&#xff1a;个人主页 &#x1f525; 个人专栏 :《 VUE 》 《 javaScript 》 &#x1f4dd; 个人网站 :《 江城开朗的豌豆&#x1fadb; 》 ⛺️ 生活的理想&#xff0c;就是为了理想的生活 ! ​ 目录 ⭐ 专栏简介 &#x1f4d8; 文章引言 一…

智能PDU在现代智慧医院机房末端配电系统中的应用分析

随着智慧医院的高速建设和发展&#xff0c;电子计算机及各类通讯设备在医院中广泛应用&#xff0c;医院信息化程度在不断提高&#xff0c;对医院内网、外网、无线网及设备网的稳定运行提出了更高的要求。信息机房作为医院所有网络数据存储交换的中心&#xff0c;它的7x24小时安…

Outlook搜索功能不全

Outlook搜索功能不全 解决方案 1、当打开Outlook想搜索内容&#xff0c;但无法搜索或者搜索不全时。 2、关掉Outlook在桌面上找到此电脑&#xff0c;右键管理 3、进入计算机管理后---服务和应用---服务----找到Windows Search--右键启动或者重新启动即可

(1)(1.14) LightWare SF10/SF11激光雷达

文章目录 前言 1 串行连接 2 I2C 连接 3 参数说明 前言 Lightware SF20 和 LW20 是体积小、测距远&#xff08;100m&#xff09;、精度高的测距仪。有两种型号&#xff0c;LW20/Ser 使用串行接口&#xff0c;LW20/I2C 使用 I2C 接口。 1 串行连接 对于串行连接&#xff0…

泊车功能专题介绍 ———— AVP系统技术要求之地图数据感知要求

文章目录 地图数据规范地图图层和表达要求地图各类数据属性要求SLAM地图要求坐标系数据采集车传感器数据采集数据流程 感知功能要求车端感知功能关键安全感知次要安全感知功能感知体验相关感知 车-场协同感知类型一&#xff1a;引导类型二&#xff1a;重点地段增强类型三&#…

万界星空科技智能管理系统低代码平台

低代码平台正成为企业数字化基础设施的重要一环&#xff0c;越来越多的企业为了可持续的数字化建设&#xff0c;开始启用低代码平台&#xff0c;其选型除了平台易用性、应用搭建能力外&#xff0c;也关注与第三方平台的集成性&#xff0c;及厂商对行业knowhow的积累、品牌口碑及…

Outlook无法打印邮件

Outlook无法打印邮件 故障现象 Outlook选择文件无法打印&#xff0c;提示“除非选定某个项目&#xff0c;否则无法打印&#xff0c;请选定某个项目&#xff0c;然后再试” 故障截图 故障原因 此目录配置文件异常C:\Users\"用户名"\AppData\Roaming\Microsoft\Out…

【JAVA学习笔记】69 - 多用户通信系统

项目代码 https://github.com/yinhai1114/Java_Learning_Code/tree/main/QQClient https://github.com/yinhai1114/Java_Learning_Code/tree/main/QQServer 〇、环境设置以及前言 该项目内会弱化UI界面的设计&#xff0c;因为JAVA本质不是用来开发界面的。 项目开发流程 对于…

Final Cut Pro X for Mac:打造专业级视频剪辑的终极利器

随着数字媒体技术的不断发展&#xff0c;视频剪辑已经成为各行各业不可或缺的一部分。Final Cut Pro X for Mac作为一款专业的视频剪辑软件&#xff0c;凭借其强大的功能和易用性&#xff0c;已经成为Mac用户的首选。本文将向您详细介绍Final Cut Pro X for Mac的优势、功能以及…